加餐|愚昧之巅:你的Rust学习常见问题汇总

你好,我是陈天。

到目前为止,我们已经学了很多 Rust 的知识,比如基本语法、内存管理、所有权、生命周期等,也展示了三个非常有代表性的示例项目,让你了解接近真实应用环境的 Rust 代码是什么样的。

虽然学了这么多东西,你是不是还是有种“一学就会,一写就废”的感觉?别着急,饭要一口一口吃,任何新知识的学习都不是一蹴而就的,我们让子弹先飞一会。你也可以鼓励一下自己,已经完成了这么多次打卡,继续坚持。

在今天这个加餐里我们就休个小假,调整一下学习节奏,来聊一聊 Rust 开发中的常见问题,希望可以解决你的一些困惑。

所有权问题

Q:如果我想创建双向链表,该怎么处理?

Rust 标准库有 LinkedList,它是一个双向链表的实现。但是当你需要使用链表的时候,可以先考虑一下,同样的需求是否可以用列表 Vec<T>、循环缓冲区 VecDeque<T> 来实现。因为,链表对缓存非常不友好,性能会差很多。

如果你只是好奇如何实现双向链表,那么可以用之前讲的 Rc / RefCell (第9讲)来实现。对于链表的 next 指针,你可以用 Rc;对于 prev 指针,可以用 Weak

Weak 相当于一个弱化版本的 Rc,不参与到引用计数的计算中,而Weak 可以 upgrade 到 Rc 来使用。如果你用过其它语言的引用计数数据结构,你应该对 Weak 不陌生,它可以帮我们打破循环引用。感兴趣的同学可以自己试着实现一下,然后对照这个参考实现

你也许好奇为什么 Rust 标准库的 LinkedList 不用 Rc/Weak,那是因为标准库直接用 NonNull 指针和 unsafe。

Q:编译器总告诉我:“use of moved value” 错误,该怎么破?

这是我们初学 Rust 时经常会遇到的错误,这个错误是说你在试图访问一个所有权已经移走的变量

对于这样的错误,首先你要判断,这个变量真的需要被移动到另一个作用域下么?如果不需要,可不可以使用借用?(第8讲)如果的确需要移动给另一个作用域的话:

  1. 如果需要多个所有者共享同一份数据,可以使用 Rc / Arc,辅以 Cell / RefCell / Mutex / RwLock。(第9讲
  2. 如果不需要多个所有者共享,那可以考虑实现 Clone 甚至 Copy。(第7讲

生命周期问题

Q:为什么我的函数返回一个引用的时候,编译器总是跟我过不去?

函数返回引用时,除非是静态引用,那么这个引用一定和带有引用的某个输入参数有关。输入参数可能是 &self、&mut self 或者 &T / &mut T。我们要建立正确的输入和返回值之间的关系,这个关系和函数内部的实现无关,只和函数的签名有关

比如 HashMap 的 get() 方法

pub fn get<Q: ?Sized>(&self, k: &Q) -> Option<&V>
    where
        K: Borrow<Q>,
        Q: Hash + Eq

我们并不用实现它或者知道它如何实现,就可以确定返回值 Option<&V> 到底跟谁有关系。因为这里只有两个选择:&self 或者 k: &Q。显然是 &self,因为 HashMap 持有数据,而 k 只是用来在 HashMap 里查询的 key。

这里为什么不需要使用生命周期参数呢?因为我们之前讲的规则:当 &self / &mut self 出现时,返回值的生命周期和它关联。(第10讲)这是一个很棒的规则,因为大部分方法,如果返回引用,它基本上是引用 &self 里的某个数据。

如果你能搞明白这一层关系,那么就比较容易处理,函数返回引用时出现的生命周期错误。

当你要返回在函数执行过程中,创建的或者得到的数据,和参数无关,那么无论它是一个有所有权的数据,还是一个引用,你只能返回带所有权的数据。对于引用,这就意味着调用 clone() 或者 to_owned() 来,从引用中得到所有权。

数据结构问题

Q:为什么 Rust 字符串这么混乱,有 String、&String、&str 这么多不同的表述?

我不得不说,这是一个很有误导性的问题,因为这个问题有点胡乱总结的倾向,很容易把人带到沟里。

首先,任何数据结构 T,都可以有指向它的引用 &T,所以 String 跟 &String的区别,以及 String 跟 &str的区别,压根是两个问题

更好的问题是:为什么有了 String,还要有 &str?或者,更通用的问题:为什么 String、Vec<T> 这样存放连续数据的容器,还要有切片的概念呢?

一旦问到点子上,答案不言自喻,因为切片是一个非常通用的数据结构。

用过 Python 的人都知道:

s = "hello world"
let slice1 = s[:5] # 可以对字符串切片
let slice2 = slice1[1:3] # 可以对切片再切片
print(slice1, slice2) # 打印 hello, el

这和 Rust 的 String 切片何其相似:

let s = "hello world".to_string();
let slice1 = &s[..5]; // 可以对字符串切片
let slice2 = &slice1[1..3]; // 可以对切片再切片
println!("{} {}", slice1, slice2); // 打印 hello el

所以 &str 是 String 的切片,也可以是 &str 的切片。它和 &[T] 一样,没有什么特别的,就是一个带着长度的胖指针,指向了一片连续的内存区域。

你可以这么理解:切片之于 Vec<T> / String 等数据,就好比数据库里的视图(view)之于表(table)。关于这个问题我们会在后面,讲Rust的数据结构时详细讲到。

Q:在课程的示例代码中,用了很多 unwrap(),这样可以么?

当我们需要从 Option 或者 Result<T, E> 中获得数据时,可以使用 unwrap(),这是示例代码出现 unwrap() 的原因。

如果我们只是写一些学习性质的代码,那么 unwrap() 是可以接受的,但在生产环境中,除非你可以确保 unwrap() 不会引发 panic!(),否则应该使用模式匹配来处理数据,或者使用错误处理的 ? 操作符。我们后续会有专门一讲聊 Rust 的错误处理。

那什么情况下我们可以确定 unwrap() 不会 panic 呢?如果在做 unwrap() 之前,Option<T> 或者 Result<T, E> 中已经有合适的值(Some(T) 或者 Ok(T)),你就可以做 unwrap()。比如这样的代码:

// 假设 v 是一个 Vec<T>
if v.is_empty() {
    return None;
}

// 我们现在确定至少有一个数据,所以 unwrap 是安全的
let first = v.pop().unwrap();

Q:为什么标准库的数据结构比如 Rc / Vec 用那么多 unsafe,但别人总是告诉我,unsafe 不好?

好问题。C 语言的开发者也认为 asm 不好,但 C 的很多库里也大量使用 asm。

标准库的责任是,在保证安全的情况下,即使牺牲一定的可读性,也要用最高效的手段来实现要实现的功能;同时,为标准库的用户提供一个优雅、高级的抽象,让他们可以在绝大多数场合下写出漂亮的代码,无需和丑陋打交道。

Rust中,unsafe 代码把程序的正确性和安全性交给了开发者来保证,而标准库的开发者花了大量的精力和测试来保证这种正确性和安全性。而我们自己撰写 unsafe 代码时,除非有经验丰富的开发者 review 代码,否则,有可能疏于对并发情况的考虑,写出了有问题的代码。

所以只要不是必须,建议不要写 unsafe 代码。毕竟大部分我们要处理的问题,都可以通过良好的设计、合适的数据结构和算法来实现

Q:在 Rust 里,我如何声明全局变量呢?

第3讲里,我们讲过 const 和 static,它们都可以用于声明全局变量。但注意,除非使用 unsafe,static 无法作为 mut 使用,因为这意味着它可能在多个线程下被修改,所以不安全:

static mut COUNTER: u64 = 0; 

fn main() {
    COUNTER += 1; // 编译不过,编译器告诉你需要使用 unsafe
}

如果你的确想用可写的全局变量,可以用 Mutex<T>,然而,初始化它很麻烦,这时,你可以用一个库 lazy_static。比如(代码):

use lazy_static::lazy_static;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};

lazy_static! {
    static ref HASHMAP: Arc<Mutex<HashMap<u32, &'static str>>> = {
        let mut m = HashMap::new();
        m.insert(0, "foo");
        m.insert(1, "bar");
        m.insert(2, "baz");
        Arc::new(Mutex::new(m))
    };
}

fn main() {
    let mut map = HASHMAP.lock().unwrap();
    map.insert(3, "waz");

    println!("map: {:?}", map);
}

调试工具

Q:Rust 下,一般如何调试应用程序?

我自己一般会用 tracing 来打日志,一些简单的示例代码会使用 println! / dbg! ,来查看数据结构在某个时刻的状态。而在平时的开发中,我几乎不会用调试器设置断点单步跟踪。

因为与其浪费时间在调试上,不如多花时间做设计。在实现的时候,添加足够清晰的日志,以及撰写合适的单元测试,来确保代码逻辑上的正确性。如果你发现自己总需要使用调试工具单步跟踪才能搞清楚程序的状态,说明代码没有设计好,过于复杂。

当我学习 Rust 时,会常用调试工具来查看内存信息,后续的课程中我们会看到,在分析有些数据结构时使用了这些工具。

Rust 下,我们可以用 rust-gdbrust-lldb,它们提供了一些对 Rust 更友好的 pretty-print 功能,在安装 Rust 时,它们也会被安装。我个人习惯使用 gdb,但 rust-gdb 适合在 linux 下,在 OS X 下有些问题,所以我一般会切到 Ubuntu 虚拟机中使用 rust-gdb。

其它问题

Q:为什么 Rust 编译出来的二进制那么大?为什么 Rust 代码运行起来那么慢?

如果你是用 cargo build 编译出来的,那很正常,因为这是个 debug build,里面有大量的调试信息。你可以用 cargo build --release 来编译出优化过的版本,它会小很多。另外,还可以通过很多方法进一步优化二进制的大小,如果你对此感兴趣,可以参考这个文档

Rust的很多库如果你不用 --release 来编译,它不会做任何优化,有时候甚至感觉比你的 Node.js 代码还慢。所以当你要把代码应用在生产环境,一定要使用 release build。

Q:这门课使用什么样的 Rust 版本?会随着 2021 edition 更新么?

会的。Rust 是一门不断在发展的语言,每六周就会有一个新的版本诞生,伴随着很多新的功能。比如 const generics代码):

#[derive(Debug)]
struct Packet<const N: usize> {
    data: [u8; N],
}

fn main() {
    let ip = Packet { data: [0u8; 20] };
    let udp = Packet { data: [0u8; 8] };
    
    println!("ip: {:?}, udp: {:?}", ip, udp);
}

再比如最近刚发的 1.55 支持了 open range pattern(代码):

fn main() {
    println!("{}", match_range(10001));
}

fn match_range(v: usize) -> &'static str {
    match v {
        0..=99 => "good",
        100..=9999 => "unbelievable",
        10000.. => "beyond expectation",
        _ => unreachable!(),
    }
}

再过一个多月,Rust 就要发布 2021 edition 了。由于 Rust 良好的向后兼容能力,我建议保持使用最新的 Rust 版本。等 2021 edition 发布后,我会更新代码库到 2021 edition,文稿中的相应代码也会随之更新。

思考题

来一道简单的思考题,我们把之前学的内容融会贯通一下,代码展示了有问题的生命周期,你能找到原因么?(代码

use std::str::Chars;

// 错误,为什么?
fn lifetime1() -> &str {
    let name = "Tyr".to_string();
    &name[1..]
}

// 错误,为什么?
fn lifetime2(name: String) -> &str {
    &name[1..]
}

// 正确,为什么?
fn lifetime3(name: &str) -> Chars {
    name.chars()
}

欢迎在留言区抢答,也非常欢迎你分享这段时间的学习感受,一起交流进步。我们下节课回归正文讲Rust的类型系统,下节课见!

精选留言

  • 秋声赋

    2022-01-11 22:41:41

    我看到用了很多的宏,这个有没有详细的说明呢?
    作者回复

    06那篇有简单讲到宏,不过因为宏是高级内容在课程里目前没有详细讲解,你可以自己找资料学习。之后老师有空会补充一篇关于宏的内容,也欢迎期待~

    2022-01-13 10:06:13

  • lisiur

    2021-09-17 07:50:15

    第一个,没有标注生命周期,但即使标注也不对,因为返回值引用了本地已经 drop 的 String,会造成悬垂指针问题;

    第二个,和第一个类似,因为参数是具有所有权的 String,该 String 会在函数执行完后被 drop,返回值不能引用该 String;

    第三个,因为 Chars 的完整定义是 Chars<'a>,根据生命周期标注规则,Chars 内部的引用的生命周期和参数 name 一致,所以不会产生问题。
    作者回复

    非常棒!

    2021-09-17 10:05:04

  • 乌龙猹

    2021-09-17 11:08:22

    陈老师,啥时候再出一门 Elixir 编程的第一课啊
    作者回复

    :) 做这一门课我就感觉已经竭尽全力了,做完估计要休半年才能缓过来

    2021-09-18 09:10:15

  • Arthur

    2021-09-17 15:57:10

    lifetime1:
    name为函数内部的临时变量,类型是String,函数返回值为其引用,但引用的变量name生命周期在函数结束时,会被drop,因此此处引用失效,无值可借;

    lifetime2:
    name为具有所有权的参数,类型是String,在函数被调用时,所有权会move给name,在函数执行结束时,name会被drop,因此返回值的引用还是无值可借,编译器无法推导出合理的生命周期;

    lifetime3:
    chars()返回的iterator具有和函数参数name相同的生命周期,name本身又是一个借用,真正具有所有权的变量存活的比函数久,因此这个函数可以编译通过

    参考材料:
    编译器报错信息
    ```plain
    |
    12 | fn lifetime1() -> &str {
    | ^ expected named lifetime parameter
    |
    = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from

    --> src/main.rs:18:31
    |
    18 | fn lifetime2(name: String) -> &str {
    | ^ expected named lifetime parameter
    |
    = help: this function's return type contains a borrowed value with an elided lifetime, but the lifetime cannot be derived from the arguments

    ```

    标准库具体实现
    ```rust
    // Returns an iterator over the chars of a string slice.
    pub fn chars(&self) -> Chars<'_>

    // Converts the given value to a String.
    fn to_string(&self) -> String
    ```
    作者回复

    非常好,目前最棒的答案!

    2021-09-18 08:57:20

  • gnu

    2021-09-17 01:11:15

    lifetime1:
    返回的引用是在 lifetime1 里被分配,lifetime1 结束后引用就被回收,所以错误。
    改为转成 string 后返回。
    ```
    fn lifetime1() -> String {
    let name = "Tyr".to_string();
    name[1..].to_string()
    }
    ```


    lifetime2:
    函数参数是 String,编译器无法通过参数确定返回值 &str 的生命周期。
    修改为
    ```
    fn lifetime2(name: &String) -> &str {
    &name[1..]
    }
    ```

    lifetime3:
    返回 Chars 类型的生命周期与参数 name 关联,所以正确。
    作者回复

    非常棒!修改得也很棒!

    2021-09-17 10:06:27

  • 记事本

    2021-09-17 17:22:07

    老师,关于智能指针一些问题:
    数据放在堆上,返回指针给栈上的结构体
    智能指针有个特点,*解耦到原型,&*就是获取数据的引用,单&栈上结构体的地址
    *因为会解耦出原型,所以原数据是否实现copy trait,否则会move,智能指针就没有所有权了
    作者回复

    是的,所以 * 不能直接使用在诸如 String / Vec 这样数据结构的引用上。当你尝试这样做,编译器会报错:数据被 borrow,但又被 move,且没有实现 copy,所以错误。

    ```rust
    fn main() {
    let s = "hello".to_string();
    let r1 = &s;
    let s1 = *r1;

    println!("{:?}", s1);
    }
    ```

    所以这里会编译出错,而不是移动所有权。你不能一边借用,一边移动所有权。

    2021-09-18 09:22:53

  • 彭亚伦

    2021-10-26 09:35:31

    关于String 和 &str相关的各种问题, 我的经验, 一个核心原因是因为 String 实现了Deref<Target = str>, String和&str是通过这个Deref Trait建立了互换的关系;

    这样做带来了很多便利, 同时也有个side effect, 就是当参数要求是 &str 时, 实参可能是&str也可能是&String, 而两者的生命周期明显是不一样的, 于是就产生了各种看似比较难以琢磨的问题.
    作者回复

    &str / &String 生命周期为什么不一样?生命周期和类型无关,和数据有关。我猜你说 &str 和 &String 声明周期不一样,是因为我们会使用 string literal,比如:

    let s1 = "hello";
    let s2 = "hello".to_stirng();

    这两者生命周期不一样是因为 "hello" 本身是 &'static(编译时放在 RODATA section 中),而 s2 复制了一份 "hello" 放在堆上。

    2021-11-11 23:19:05

  • Kerry

    2021-09-18 14:16:44

    例子一:

    1. &str生命周期不明确
    2. 返回了局部函数拥有所有权的引用,也是生命周期问题

    可改为:

    fn lifetime1() -> &'static str {
    let name = "Tyr";
    &name[1..]
    }

    例子二:

    函数参数不是引用类型,而且String没有实现Copy Trait,传参的时候会把形参的所有权给到实参,这时候跟例子一是一样的。解决办法是把形参定义为引用类型,如&str(&String也不是不行):

    fn lifetime2(name: &str) -> &str {
    &name[1..]
    }

    注意这里例子二不用指定返回值的生命周期,因为编译器可以从参数列表自动推断。

    例子三:

    Chars是字符串切片迭代器,生命周期与&str是一致的,这一点可以从签名中看出:

    // std::str::chars
    pub fn chars(&self) -> Chars<'_>

    // std::str::Chars
    pub struct Chars<'a> {
    pub(super) iter: slice::Iter<'a, u8>,
    }
    作者回复

    👍 非常好。要注意的是例子 1 的改法并不是通用的,它只适合少数可以 &'static 的情况。

    2021-09-19 00:20:59

  • 罗杰

    2021-09-17 09:32:24

    比较简单的问题,第一个 name 在函数里面创建的 String,函数返回时就释放掉了,这是最直白的悬垂引用。第二个 name 是从调用者 move 过来的 String,进入该函数,所有权就归函数了,返回时 name 也将被释放。第三个 name 不用加生命周期标注可以正常工作,参数是引用,返回的数据与该参数的生命周期相同,没有问题,可以编译通过。
    作者回复

    非常棒!

    2021-09-17 10:04:21

  • 亚伦碎语

    2021-09-23 16:11:41

    对&str 和 &String的区别,更新一点:
    String可以动态的调整内存大小。 str不能resize.
    &str直接是指到了String存储的引用,&String是对于String内存对象的引用。
    参考:
    https://users.rust-lang.org/t/whats-the-difference-between-string-and-str/10177/8
    作者回复

    嗯。其实不必这么记。还是要抓住 String 和 str 的实质。一个拥有所有权(自然也可以 put_str),一个没有所有权,只是切片(自然不能 resize 别人的东西)。

    2021-09-24 14:20:41

  • Frère Jac

    2023-10-19 18:41:59

    我知道 cargo build 有 --offline 选项,
    也知道有 cargo fetch 预下载依赖,
    问题是比如我 日常开发在 mac 上进行,
    但是最终交付要在公司内网 Linux 服务器进行构件交付,不能连接外网,我该如何操作才能把需要的依赖收集好,然后 copy 至目标构件服务器进行构件呢?
  • 丁卯

    2021-10-30 12:13:46

    to_owned() 什么意思?
    作者回复

    一般是一个 clone(),得到有所有权的数据。比如 &str -> String。文档:https://doc.rust-lang.org/std/borrow/trait.ToOwned.html#tymethod.to_owned

    2021-11-10 23:14:38

  • 记事本

    2021-09-17 10:59:39

    老师,String,Vec算是智能指针吗?*String解除str,然后&*String就是&str了,Box::new()好像也可以这样用,Box::new(String::new)这样的使用,内存发生了什么变化啊
    作者回复

    String / Vec 都是智能指针。理论上任何实现了 Deref / DerefMut / Drop 的数据结构,都是智能指针。

    2021-09-18 09:11:45

  • 我还是新人

    2024-02-24 20:58:14

    思考题
    fn lifetime1() -> &str 这个name 握有值的所有权, 在函数执行完后就被销毁了,返回它的引用自然没有意义。
    fn lifetime2(name: String) -> &str 同样的道理,传进来的name也有所有权,也会在函数执行完后被销毁。
    fn lifetime3(name: &str) -> Chars 这个函数传进的是引用,没有所有权,所以函数执行完不会销毁值。同时可以将生成&str通过copy给传递出去。
    不过我有个新的问题。对于那些生成fn test() -> 'static str这样的返回static这样的函数,生命周期与进程相同,会不会导致内存泄漏呢?
  • 木鸢

    2022-10-09 19:40:25

    还没看到这里就体会到陈老师说的愚昧之颠了,rust的大量简写就当是语言特色来看了,到了所有权、rc、arc、生命周期这些章节,看完理论再对照代码就蒙圈了,代码完全看不懂啊!
    vec!和直接定义切片有什么区别?
    String::from定义的字符串和直接双引号定义的结果是一回事吗?
    生命周期标示是 ' 还是 'a-z ,参数里面中定义,和外界传参时候定义有区别吗?
    语法 spawn(|| {})到底表示什么意思?还是说这个写法是spawn独有的呢?

    肯定是我太白,太先去看看rust基础语法再来从新看,润了润了

    PS:陈老师讲得非常透彻,计算机基础,rust编程思想都娓娓道来,有rust经验的同学肯定会很有共鸣,评论区的精华帖也能看到
  • 手机失联户

    2021-11-30 10:59:38

    老师,我看课程里没有提到rust宏相关的知识点,请问后续会讲这个吗?因为有些rust项目,比如tokio都会用到宏,导致代码不是很容易懂,老师能不能后续专门出一期讲一下。
    作者回复

    之前因为宏相关的内容是高级内容,所以没放在这个课程中。

    2021-12-19 01:44:20

  • mobus

    2021-11-11 11:24:54

    老师,有没有办法快速提取 枚举值?比如jsonrpc request ,为了匹配最终请求值,代码膨胀的太厉害了
    作者回复

    match / if let 就是你最好的工具,你可以做非常深入的模式匹配

    2021-11-11 22:37:11

  • 活着

    2021-11-03 22:39:28

    老师辛苦了,课程非常好👍
    作者回复

    谢谢!

    2021-11-10 22:41:35

  • 亚伦碎语

    2021-09-23 16:06:54

    use std::str::Chars;

    // 错误,为什么?
    // name 在lifetime1 的block下就会被drop掉,所以返回&str是不对
    fn lifetime1() -> String {
    let name = "Tyr".to_string();
    name[1..].to_string()
    }

    // 错误,为什么?
    // name类型变为String, ownership改变,但是返回是引用,block结束会被drop掉。可以讲入参改为引用。
    fn lifetime2(name: &String) -> &str {
    &name[1..]
    }

    // 正确,为什么?
    // 默认和参数一样的生命周期
    fn lifetime3(name: &str) -> Chars {
    name.chars()
    }
    作者回复

    正确!

    2021-09-24 14:20:53

  • qinsi

    2021-09-20 17:28:47

    某些情景下带环的结构可以用Arena实现,比如typed_arena,用的时候不管释放,用完了一起释放。
    作者回复

    嗯,但这样实际上在 arena 内部有内存泄漏。

    2021-09-22 00:20:44