跳转至

加餐 愚昧之巅:你的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的类型系统,下节课见!

精选留言(15)
  • 秋声赋 👍(2) 💬(1)

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

    2022-01-11

  • lisiur 👍(49) 💬(1)

    第一个,没有标注生命周期,但即使标注也不对,因为返回值引用了本地已经 drop 的 String,会造成悬垂指针问题; 第二个,和第一个类似,因为参数是具有所有权的 String,该 String 会在函数执行完后被 drop,返回值不能引用该 String; 第三个,因为 Chars 的完整定义是 Chars<'a>,根据生命周期标注规则,Chars 内部的引用的生命周期和参数 name 一致,所以不会产生问题。

    2021-09-17

  • 乌龙猹 👍(22) 💬(8)

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

    2021-09-17

  • Arthur 👍(12) 💬(1)

    lifetime1: name为函数内部的临时变量,类型是String,函数返回值为其引用,但引用的变量name生命周期在函数结束时,会被drop,因此此处引用失效,无值可借; lifetime2: name为具有所有权的参数,类型是String,在函数被调用时,所有权会move给name,在函数执行结束时,name会被drop,因此返回值的引用还是无值可借,编译器无法推导出合理的生命周期; lifetime3: chars()返回的iterator具有和函数参数name相同的生命周期,name本身又是一个借用,真正具有所有权的变量存活的比函数久,因此这个函数可以编译通过 参考材料: 编译器报错信息

       |
    12 | fn lifetime1() -&gt; &amp;str {
       |                   ^ expected named lifetime parameter
       |
       = help: this function&#39;s return type contains a borrowed value, but there is no value for it to be borrowed from
    
      --&gt; src&#47;main.rs:18:31
       |
    18 | fn lifetime2(name: String) -&gt; &amp;str {
       |                               ^ expected named lifetime parameter
       |
       = help: this function&#39;s return type contains a borrowed value with an elided lifetime, but the lifetime cannot be derived from the arguments
    
    标准库具体实现
    &#47;&#47; Returns an iterator over the chars of a string slice.
    pub fn chars(&amp;self) -&gt; Chars&lt;&#39;_&gt;
    
    &#47;&#47; Converts the given value to a String.
    fn to_string(&amp;self) -&gt; String
    ```</p>2021-09-17</li><br/><li><span>gnu</span> 👍(10 💬(1<p>lifetime1: 
    返回的引用是在 lifetime1 里被分配,lifetime1 结束后引用就被回收,所以错误。
    改为转成 string 后返回。
    
    fn lifetime1() -> String { let name = "Tyr".to_string(); name[1..].to_string() }
    lifetime2:
    函数参数是 String,编译器无法通过参数确定返回值 &amp;str 的生命周期。
    修改为
    
    fn lifetime2(name: &String) -> &str { &name[1..] } ``` lifetime3: 返回 Chars 类型的生命周期与参数 name 关联,所以正确。

    2021-09-17

  • 记事本 👍(6) 💬(2)

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

    2021-09-17

  • 彭亚伦 👍(5) 💬(1)

    关于String 和 &str相关的各种问题, 我的经验, 一个核心原因是因为 String 实现了Deref<Target = str>, String和&str是通过这个Deref Trait建立了互换的关系; 这样做带来了很多便利, 同时也有个side effect, 就是当参数要求是 &str 时, 实参可能是&str也可能是&String, 而两者的生命周期明显是不一样的, 于是就产生了各种看似比较难以琢磨的问题.

    2021-10-26

  • Kerry 👍(3) 💬(1)

    例子一: 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>, }

    2021-09-18

  • 罗杰 👍(3) 💬(1)

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

    2021-09-17

  • 亚伦碎语 👍(2) 💬(1)

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

    2021-09-23

  • 丁卯 👍(1) 💬(1)

    to_owned() 什么意思?

    2021-10-30

  • 记事本 👍(1) 💬(1)

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

    2021-09-17

  • 手机失联户 👍(0) 💬(1)

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

    2021-11-30

  • mobus 👍(0) 💬(1)

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

    2021-11-11

  • 活着 👍(0) 💬(1)

    老师辛苦了,课程非常好👍

    2021-11-03