Rust 面试题
这里收集的是 Rust 面试里反复出现的问题,从初级岗位到高级岗位都有。每个答案都短到可以直接说出口,并且链接到深入讲解这个主题的课程。
不要逐字背诵。面试官一听就能听出来,而且他们通常会追问一句,看你是不是真的理解了。用这一页检查自己掌握了多少,觉得没把握的地方就去看链接的课程。如果你能用自己的话讲清楚一个答案,再写出一个小例子,那就准备好了。
问:Cargo 是什么?
Section titled “问:Cargo 是什么?”Cargo 是 Rust 官方的构建工具和包管理器,安装 Rust 时就自带了。用这一个工具,你就可以:
- 创建项目(
cargo new), - 构建并运行项目(
cargo build、cargo run), - 运行测试(
cargo test), - 从 crates.io 添加库,这些库叫做 crate(
cargo add serde)。
项目的设置和依赖写在 Cargo.toml 里。确切的版本锁定在 Cargo.lock 里,所以每次构建的结果都可以复现。一个不错的加分点:其他语言往往需要好几个工具来做这些事,而在 Rust 里大家都用 Cargo,所以每个项目看起来都很熟悉。
进一步学习:Cargo 与 crate、快速上手。
问:遮蔽和 mut 有什么区别?
Section titled “问:遮蔽和 mut 有什么区别?”mut 让你修改同一个变量的值,类型必须保持不变。
遮蔽(shadowing)是指用同一个名字再写一次 let。它会创建一个新变量,把旧的那个挡住。新变量甚至可以是不同的类型。
fn main() { let spaces = " "; let spaces = spaces.len(); // 遮蔽:一个新变量,现在是数字 println!("{spaces}");
let mut count = 1; count += 1; // mut:同一个变量得到一个新值 println!("{count}");}输出:
32遮蔽很适合一步步转换一个值(比如把文本解析成数字),不用发明 input_str、input_num 这样的名字。而且最后那个变量仍然是不可变的。
进一步学习:变量。
问:宏和函数有什么区别?
Section titled “问:宏和函数有什么区别?”函数在运行时被调用,参数的个数固定,而且都有类型。宏在编译时运行:它接收代码,在编译器检查之前生成更多代码。宏很好认,名字后面带 !,比如 println!、vec! 和 format!。
宏能做到函数做不到的事,比如接收可变数量的参数(println!("{} {}", a, b)),或者在编译时检查格式字符串。Rust 的宏处理的是代码的结构,而不是原始文本,所以能避开 C 语言宏的那些经典 bug:
macro_rules! square { ($x:expr) => { $x * $x };}
fn main() { // 在 C 语言里,基于文本替换的宏会得到 2 + 1 * 2 + 1 = 5。 println!("{}", square!(2 + 1));}输出:
9实话实说,宏也有代价:读起来、写起来、调试起来都更难。函数能搞定的事,就用函数。
进一步学习:宏。
问:“零成本抽象”是什么意思?
Section titled “问:“零成本抽象”是什么意思?”意思是高级特性不应该让程序比你手写的底层代码更慢。“你不用的东西,不需要付出代价;你用到的东西,你自己手写也写不得更好。”
比如,.iter().filter(...).map(...).sum() 这样的迭代器链,编译后的机器码通常和手写的循环一样。泛型是另一个例子:编译器会为你用到的每种类型生成一份专门的副本(这叫单态化,monomorphization),所以运行时不需要任何查找。
对“成本”要说清楚:零成本指的是运行时零成本。你仍然要在编译时间上付出代价,而且泛型可能让程序文件变大。
问:Rust 的迭代器是惰性的吗?
Section titled “问:Rust 的迭代器是惰性的吗?”是的。.map() 和 .filter() 这样的方法自己不做任何工作,它们只是构建出一份“要做什么”的描述。直到有东西消费这个迭代器,比如 .collect()、.sum() 或者 for 循环,才会真正执行。
fn main() { let numbers = vec![1, 2, 3]; let doubled = numbers.iter().map(|n| { println!("正在把 {n} 翻倍"); n * 2 });
println!("到这里还什么都没执行。"); let result: Vec<i32> = doubled.collect(); println!("{:?}", result);}输出:
到这里还什么都没执行。正在把 1 翻倍正在把 2 翻倍正在把 3 翻倍[2, 4, 6]这也是为什么当你写了 numbers.iter().map(...); 却从不使用结果时,编译器会给出警告:这行代码什么都不会做。
进一步学习:迭代器。
所有权与借用
Section titled “所有权与借用”问:所有权规则是什么?
Section titled “问:所有权规则是什么?”一共有三条:
- Rust 中的每个值都有一个所有者(一个变量)。
- 值同一时间只能有一个所有者。
- 当所有者离开作用域时,值会被丢弃(它的内存被释放)。
靠这些规则,Rust 能在恰好的时机释放内存,既不需要垃圾回收器,也不需要你手动调用 free。编译器会检查这些规则,所以在安全代码里,“释放后使用”(use after free)和“重复释放”(double free)这类错误不可能发生。
问:移动、复制和克隆有什么区别?
Section titled “问:移动、复制和克隆有什么区别?”- 移动(move):赋值或传参会转移所有权,旧变量之后就不能再用了。对于
String和Vec这样的类型,这是默认行为。除了一个小小的指针、长度和容量之外,什么都不会被复制。 - 复制(copy):对于
i32、f64、bool和char这样小而简单的类型,赋值会做一次廉价的按位复制,两个变量都还能用。 - 克隆(clone):
.clone()是一次显式的深拷贝。对String来说,它会把所有文本复制到新的内存里。这可能很昂贵,所以 Rust 要你亲手写出来。
fn main() { let x = 5; let y = x; // 复制:x 仍然可以用 println!("{x} {y}");
let a = String::from("hi"); let b = a.clone(); // 克隆:把文本完整复制一份 let c = a; // 移动:这一行之后 `a` 就不能用了 println!("{b} {c}");}输出:
5 5hi hi进一步学习:所有权。
问:Copy trait 是什么?哪些类型实现了它?
Section titled “问:Copy trait 是什么?哪些类型实现了它?”Copy 是一个标记 trait,它表示“这个类型只要复制它的比特位就能得到一份副本”。实现了 Copy 的类型在赋值时会被复制,而不是被移动。
整数、浮点数、bool、char、共享引用(&T),以及只由 Copy 类型组成的元组或数组,都是 Copy 的。String、Vec 和 Box 不是,因为它们在别处拥有一块内存,只复制指针就会出现两个所有者。
你可以为自己的类型派生(derive)它,但前提是每个字段都是 Copy:
#[derive(Clone, Copy)]struct User { name: String, age: u32,}error[E0204]: the trait `Copy` cannot be implemented for this type --> src/main.rs:2:8 |1 | #[derive(Clone, Copy)] | ---- in this derive macro expansion2 | struct User { | ^^^^3 | name: String, | ------------ this field does not implement `Copy`编译器说:不能为这个类型实现 Copy,因为 name 字段(String)没有实现 Copy。
Copy 总是同时要求 Clone。另外,实现了 Drop 的类型不能是 Copy。
问:String 和 &str 有什么区别?
Section titled “问:String 和 &str 有什么区别?”String是拥有所有权、可以增长的一段文本,存放在堆上。你可以修改它,比如用push_str。&str(“字符串 slice”)是对某段文本的借用视图,它只是一个指针加一个长度。像"hello"这样的字符串字面量就是&str。
一条好用的规则:只读取文本的函数应该接收 &str。这样它两种都能接受,因为 &String 会自动转换成 &str。
fn greet(name: &str) { println!("你好,{name}!");}
fn main() { let owned: String = String::from("Ana"); let literal: &str = "Ben";
greet(&owned); // 需要 &str 的地方可以传 &String greet(literal);
let mut full = owned; full.push_str(" Smith"); // 只有 String 才能增长 greet(&full);}输出:
你好,Ana!你好,Ben!你好,Ana Smith!问:借用规则是什么?
Section titled “问:借用规则是什么?”在任意时刻,对于一个给定的值,你要么拥有:
- 任意数量的共享引用(
&T),要么 - 恰好一个可变引用(
&mut T),
但不能两者同时存在。而且每个引用都必须始终指向有效的数据。
为什么?如果有人能在别人读取数据的同时修改它,就会出现 bug,比如你手里拿着指向 vector 内部的指针,vector 却把内存搬走了。放到多线程里,这正是数据竞争(data race)的定义。Rust 在编译时就阻止了它。从 2018 edition 开始,编译器能聪明地判断借用何时结束(这叫非词法作用域生命周期,non-lexical lifetimes):借用只持续到最后一次使用,而不是到代码块结束。
进一步学习:借用。
问:生命周期是什么?
Section titled “问:生命周期是什么?”生命周期是编译器对“一个引用在多长时间内有效”的称呼。每个引用都有生命周期。大多数时候编译器能自己推断出来(这叫生命周期省略,lifetime elision),所以你根本看不到它们。
只有当编译器分不清输出的引用和输入之间是什么关系时,你才需要写出来,比如一个返回两个借用字符串之一的函数:
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str { if a.len() >= b.len() { a } else { b }}
fn main() { let first = String::from("borrow checker"); let second = String::from("lifetimes"); println!("{}", longest(&first, &second));}输出:
borrow checker这里的两个字符串保留了英文,因为 longest 比较的是 len(),也就是字节数,而一个汉字占 3 个字节。
面试时值得强调的一点:生命周期注解不会改变任何值活多久。它们只是描述引用之间的关系,好让编译器检查没有引用比它指向的数据活得更久。
进一步学习:生命周期。
类型与 trait
Section titled “类型与 trait”问:Rust 没有 null,那它用什么代替?
Section titled “问:Rust 没有 null,那它用什么代替?”用 Option<T>,一个有两个变体的枚举:Some(value) 或 None。因为它和 T 是不同的类型,编译器会强制你先处理 None 的情况,然后才能使用里面的值。你不可能忘记检查 null,因为忘了代码就编译不过。
fn find_user(id: u32) -> Option<&'static str> { match id { 1 => Some("Ana"), 2 => Some("Ben"), _ => None, }}
fn main() { for id in [1, 3] { match find_user(id) { Some(name) => println!("找到了 {name}"), None => println!("没有 id 为 {id} 的用户"), } }}输出:
找到了 Ana没有 id 为 3 的用户一个不错的补充细节:和普通指针相比,Option<&T> 和 Option<Box<T>> 不占用额外的内存。Rust 在内部用“null”的比特模式来表示 None,所以这里的安全是零成本的。
问:trait 是什么?它和接口一样吗?
Section titled “问:trait 是什么?它和接口一样吗?”trait 定义了一组类型可以实现的方法。它和 Java、C# 或 Go 里的接口很像,用这句话开头回答完全没问题。然后再说说区别:
- trait 可以有默认方法实现。
- 你可以在类型写好之后再为它实现 trait,甚至可以为不是你写的类型实现,比如为
i32实现你自己的 trait。(“孤儿规则”,即 orphan rule,要求 trait 和类型至少有一个是你自己定义的。) - trait 既可以用作泛型约束(
T: Display),在编译时确定;也可以用作 trait 对象(dyn Display),在运行时确定。 - Rust 没有类继承,用 trait 加组合来代替它。
问:泛型和 trait 对象有什么区别?
Section titled “问:泛型和 trait 对象有什么区别?”两者都能让一个函数处理多种类型。区别在于类型是什么时候确定的:
- 泛型(
fn f<T: Speak>(x: &T))使用静态分发。编译器为每种类型生成一份单独的函数副本。调用和普通函数调用一样快,但程序可能会变大。 - trait 对象(
&dyn Speak、Box<dyn Speak>)使用动态分发。函数只有一份,运行时通过一张表(“vtable”,虚表)找到正确的方法。会稍微慢那么一点点,但能让你在一个集合里混放不同的类型。
trait Speak { fn speak(&self) -> String;}
struct Dog;struct Cat;
impl Speak for Dog { fn speak(&self) -> String { String::from("汪汪") }}
impl Speak for Cat { fn speak(&self) -> String { String::from("喵喵") }}
fn speak_static<T: Speak>(animal: &T) -> String { animal.speak()}
fn speak_dynamic(animal: &dyn Speak) -> String { animal.speak()}
fn main() { println!("{}", speak_static(&Dog)); println!("{}", speak_dynamic(&Cat));
// 只有 trait 对象才能在一个 Vec 里混放不同类型: let animals: Vec<Box<dyn Speak>> = vec![Box::new(Dog), Box::new(Cat)]; for animal in &animals { println!("{}", animal.speak()); }}输出:
汪汪喵喵汪汪喵喵经验法则:先用泛型。当你需要一个装着不同类型的集合,或者想控制编译时间和二进制文件大小时,再用 trait 对象。
问:Box<dyn Trait> 和 impl Trait 有什么区别?
Section titled “问:Box<dyn Trait> 和 impl Trait 有什么区别?”两者都能让你表达“某个实现了这个 trait 的类型”,而不用写出它的名字。区别在于允许多少种具体类型:
impl Trait表示一个具体类型,编译器知道它是什么,只是不要求你写出来。它是静态分发:没有堆分配,没有 vtable,和你亲手写出类型名一样快。在参数位置(fn f(x: impl Display)),它只是泛型的一种简写。在返回值位置,它是返回那些类型名很长或者根本写不出名字的东西(比如闭包和迭代器链)的方式。Box<dyn Trait>是放在堆上的 trait 对象。实际类型每次都可以不同,在运行时决定,方法通过 vtable 找到。这要付出一次内存分配和一次间接调用的代价,但它是返回多种类型之一、或者把它们存进同一个Vec的唯一办法。
use std::fmt::Display;
// impl Trait:一个隐藏的类型,在编译时就固定了。fn evens(limit: u32) -> impl Iterator<Item = u32> { (0..limit).filter(|n| n % 2 == 0)}
// Box<dyn Trait>:真实类型可以在运行时变化。fn label(as_number: bool) -> Box<dyn Display> { if as_number { Box::new(42) } else { Box::new("四十二") }}
fn main() { let v: Vec<u32> = evens(10).collect(); println!("{v:?}"); println!("{}", label(true)); println!("{}", label(false));}输出:
[0, 2, 4, 6, 8]42四十二一个值得一提的细节:如果把 label 改成返回 impl Display 并去掉 Box,代码就编译不过了。if 返回的是 i32,else 返回的是 &str,而 impl Trait 必须是单一的类型。经验法则:默认用 impl Trait,只有真的需要在运行时从几种类型中选一种时,才换成 Box<dyn Trait>。
问:为什么 match 必须是穷尽的?
Section titled “问:为什么 match 必须是穷尽的?”match 必须处理每一种可能的值,否则代码编译不过。这意味着当你给枚举加一个新变体时,编译器会把每一个需要更新的地方都指给你看。在其他语言里,switch 中漏掉的一个 case 会悄无声息地什么都不做。
你可以用 _ 兜底,但要小心:兜底分支也会把你以后新加的变体藏起来,这层安全网就没了。
问:没有异常,Rust 怎么处理错误?
Section titled “问:没有异常,Rust 怎么处理错误?”可恢复的错误就是 Result<T, E> 类型的普通值:要么是 Ok(value),要么是 Err(error)。可能失败的函数会在返回类型里写明这一点,调用者必须处理它。不会有任何东西像异常那样,悄悄地沿着调用栈往上跳。
好处是:看函数签名就知道它可能失败,而且如果你忽略了一个 Result,编译器会发出警告。代价是要多打一些字,而 ? 运算符基本上把这部分也省掉了。
进一步学习:Result。
问:什么时候该用 panic!,而不是返回 Result?
Section titled “问:什么时候该用 panic!,而不是返回 Result?”- 当失败是意料之中的、调用者也能合理处理时,返回
Result:文件不存在、用户输入有误、网络超时。 - 当出现的是 bug 时就 panic,也就是程序到达了一个“本不该出现”的状态,继续运行下去就是错的。越过 vector 末尾去取索引会 panic,就是这个原因。
在测试、示例和快速原型里,panic 也完全没问题。在库里,不要因为调用者能控制的输入而 panic,应该返回一个错误,让调用者来决定。
问:unwrap、expect 和 ? 有什么区别?
Section titled “问:unwrap、expect 和 ? 有什么区别?”unwrap()给你Ok/Some里面的值,否则就带着一条通用的信息 panic。expect("msg")做的事一样,但 panic 时用的是你自己的信息。用它来说明你为什么认为这里不会失败。?不会 panic。遇到Err/None时,它会从当前函数返回这个错误,让调用者去处理。当前函数本身必须返回Result或Option。
use std::num::ParseIntError;
fn double(text: &str) -> Result<i32, ParseIntError> { let n: i32 = text.parse()?; // 出错时,把错误返回给调用者 Ok(n * 2)}
fn main() { println!("{:?}", double("21")); println!("{:?}", double("abc"));
let n: i32 = "7".parse().expect("写死在代码里的数字一定能解析"); println!("{n}");}输出:
Ok(42)Err(ParseIntError { kind: InvalidDigit })7在生产代码里,优先用 ?。当失败意味着 bug 时用 expect,unwrap 留给测试和快速实验。
问:怎样创建自己的错误类型?
Section titled “问:怎样创建自己的错误类型?”通常是用一个枚举列出代码可能失败的各种方式,再实现 std::fmt::Display 和 std::error::Error。为其他错误类型实现 From,就能让 ? 自动完成转换。
实际项目里,很多人会用辅助 crate:在库里用 thiserror(英文)定义错误类型,在只需要带着上下文把错误往上传的应用程序里用 anyhow(英文)。写快速的小程序时,Box<dyn Error> 可以装下任何错误。
进一步学习:自定义错误类型。
问:什么时候用 anyhow,什么时候用 thiserror?
Section titled “问:什么时候用 anyhow,什么时候用 thiserror?”它们解决的是同一个问题的两半,所以老实的回答是“经常两个都用”:
thiserror用于库,或者任何调用者需要针对具体错误做出反应的代码。你自己写错误枚举,#[derive(Error)]帮你补上Display和Error的样板代码。调用者拿到的是一个真正的类型,可以对它match,比如ConfigError::MissingField。anyhow用于应用程序,大多数错误只需要报告出来,而不需要逐个处理。每个函数都返回anyhow::Result<T>,?几乎能接受任何错误类型,.context("while reading config")还能在上面加一句方便人看懂的说明。代价是:调用者没法再轻松地对错误match了。
常见的搭配是在库 crate 里用 thiserror,在 main 和二进制程序的其余部分用 anyhow。两个 crate 都没有什么魔法,它们只是在标准的 std::error::Error trait 之上省掉了样板代码,所以你随时都可以手写出同样的东西。
进一步学习:anyhow 与 thiserror、自定义错误类型。
内存与智能指针
Section titled “内存与智能指针”问:Rust 有垃圾回收器吗?
Section titled “问:Rust 有垃圾回收器吗?”没有。内存会在它的所有者离开作用域时释放,释放的时间点编译器事先就知道(这叫 RAII,负责清理的方法是 Drop)。这样你就能得到可预测的性能,没有 GC 停顿。
当你确实需要共享所有权时,可以主动选用引用计数(Rc 或 Arc),它会在最后一个所有者消失时释放这个值。
问:什么时候用 Box、Rc、Arc 和 RefCell?
Section titled “问:什么时候用 Box、Rc、Arc 和 RefCell?”Box<T>:一个所有者,值放在堆上。用于递归类型(比如树或链表)、很大的值,或者 trait 对象(Box<dyn Trait>)。Rc<T>:单个线程内的多个所有者。它统计引用的数量,计数归零时释放这个值。数据是只读的。Arc<T>:和Rc一样,但计数是原子操作,所以可以安全地跨线程共享。比Rc稍慢一点。RefCell<T>:让你通过共享引用修改数据(“内部可变性”)。借用规则在运行时而不是编译时检查,违反规则就会 panic。
它们可以组合使用。Rc<RefCell<T>> 表示“在单个线程内共享、并且可以修改”,Arc<Mutex<T>> 是它的线程安全版本。
use std::cell::RefCell;use std::rc::Rc;
fn main() { let shared = Rc::new(RefCell::new(vec![1, 2])); let other_owner = Rc::clone(&shared);
other_owner.borrow_mut().push(3);
println!("{:?}", shared.borrow()); println!("所有者数量:{}", Rc::strong_count(&shared));}输出:
[1, 2, 3]所有者数量:2进一步学习:智能指针。
问:安全的 Rust 会内存泄漏吗?
Section titled “问:安全的 Rust 会内存泄漏吗?”会,坦白说出来才是诚实的回答。Rust 保证的是内存安全(没有释放后使用、没有悬垂指针、没有数据竞争),而内存泄漏并不被认为是不安全的。常见的两种情况:
Rc循环:两个值用Rc互相指向,计数永远到不了零。解决办法是把其中一个方向改成Weak引用。Box::leak和std::mem::forget这样的函数,它们是故意泄漏的。
在日常代码里,泄漏很少见,因为所有权几乎会自动释放所有东西。
进一步学习:智能指针。
问:unsafe 是什么?什么时候会用到?
Section titled “问:unsafe 是什么?什么时候会用到?”unsafe 标记一个代码块(或函数),在里面你可以做五件编译器无法检查的额外操作,比如解引用裸指针、调用 unsafe 函数(包括通过 FFI 调用 C 代码),或者访问可变的静态变量。在这里面,由你来保证规则得到遵守。
重要的一点:unsafe 不会关掉借用检查器或其他检查。它只是解锁了那几种额外的操作。
它用于和 C 库打交道、编写底层数据结构,以及在编译器无法证明安全的地方榨取性能。标准库本身就在 Vec 这样的安全 API 内部用到了 unsafe。好的做法是:让 unsafe 块尽量小,用安全的 API 把它包起来,并写一条 // SAFETY: 注释说明它为什么是正确的。大多数应用代码根本用不到它。
进一步学习:unsafe Rust。
问:Send 和 Sync 是什么意思?
Section titled “问:Send 和 Sync 是什么意思?”它们是编译器用来保证线程安全的标记 trait:
Send:这个类型的值可以被移动到另一个线程。Sync:这个类型的值可以在线程之间通过引用共享(即&T是Send)。
大多数类型两个都实现了,而且是编译器自动实现的。最有名的例外是 Rc,它的计数不是线程安全的。试着把一个 Rc 发送到另一个线程,代码就编译不过:
use std::rc::Rc;use std::thread;
fn main() { let data = Rc::new(5); let handle = thread::spawn(move || { println!("{data}"); }); handle.join().unwrap();}error[E0277]: `Rc<i32>` cannot be sent between threads safely ... = help: within `{closure@src/main.rs:6:32: 6:39}`, the trait `Send` is not implemented for `Rc<i32>`编译器说:Rc<i32> 不能安全地在线程之间发送,因为它没有实现 Send。
把 Rc 换成 Arc 就能运行了。人们说 Rust 的“无畏并发”(fearless concurrency),很大一部分指的就是这个:线程安全上的错误会变成编译错误。
进一步学习:并发。
问:怎样在线程之间共享可修改的数据?
Section titled “问:怎样在线程之间共享可修改的数据?”经典答案是 Arc<Mutex<T>>。Arc 让多个线程共同拥有这个值。Mutex 保证同一时间只有一个线程能修改它:你调用 .lock(),会得到一个守卫(guard),它在离开作用域时自动解锁。
use std::sync::{Arc, Mutex};use std::thread;
fn main() { let counter = Arc::new(Mutex::new(0)); let mut handles = Vec::new();
for _ in 0..4 { let counter = Arc::clone(&counter); handles.push(thread::spawn(move || { for _ in 0..1000 { *counter.lock().unwrap() += 1; } })); }
for handle in handles { handle.join().unwrap(); }
println!("总数:{}", *counter.lock().unwrap());}输出:
总数:4000还可以提一下替代方案:不共享内存,而是用信道(std::sync::mpsc)在线程之间发送消息。对于简单的计数器,AtomicUsize 这样的原子类型可以完全不用锁。
进一步学习:并发。
问:信道和用 Mutex 共享状态相比如何?
Section titled “问:信道和用 Mutex 共享状态相比如何?”它们是在线程之间分担工作的两种风格:
- 信道(
std::sync::mpsc)传递的是消息。一个值被移动进信道,从另一头出来,所以同一时间只有一个线程拥有它。它适合流水线和工作线程池:“工作线程生产结果,一个线程负责收集。”信道通常更容易推理,因为没有两个线程会碰同一份数据。 - 共享状态(
Arc<Mutex<T>>)指多个线程使用同一份数据,通过锁轮流访问。当很多线程需要读取和更新同一个结构时,它很合适,比如一个缓存或一张计数表。风险是死锁,以及线程不停等锁导致的变慢。
下面是上一个答案里的计数器,用信道重写。每个工作线程各自计数,不用锁,然后把结果发送出去:
use std::sync::mpsc;use std::thread;
fn main() { let (tx, rx) = mpsc::channel();
for _ in 0..4 { let tx = tx.clone(); thread::spawn(move || { let mut count = 0; for _ in 0..1000 { count += 1; } tx.send(count).unwrap(); }); } drop(tx); // 丢弃最初的发送端,否则 rx.iter() 永远不会结束
let total: i32 = rx.iter().sum(); println!("总数:{total}");}输出:
总数:4000drop(tx) 这一行是很值得一提的细节:rx.iter() 会一直等到所有发送端都消失,所以忘了这一行,程序就会卡住不动。两种风格下,Rust 都会在编译时检查线程安全。选哪种取决于哪种设计能把你的问题表达得更清楚,很多程序两种都用。
进一步学习:信道(channel)、并发。
问:Rust 能防止所有并发 bug 吗?
Section titled “问:Rust 能防止所有并发 bug 吗?”不能,能说出这一点才显得你真的懂。安全的 Rust 防止的是数据竞争(两个线程同时访问同一块内存,并且至少有一个在写)。它不能防止:
- 死锁,比如两个线程各自在等对方持有的锁。
- 逻辑上的竞态条件,比如先检查一个值,再根据它采取行动,而这期间另一个线程已经把它改了。
所以 Rust 消灭了一整类难缠的 bug,但你仍然需要仔细设计加锁的方式。
进一步学习:并发。
问:异步和线程有什么区别?
Section titled “问:异步和线程有什么区别?”- 线程由操作系统运行。每个线程都有自己的栈,由操作系统在它们之间切换。线程非常适合 CPU 密集型的工作,但成千上万个线程会占用大量内存。
- 异步(async)任务要轻量得多。
async fn返回一个Future,由一个运行时(通常是 Tokio(英文))在少数几个线程上运行许多 future。当一个任务在等待时(等网络、等定时器、等文件),.await会让其他任务先运行。它非常适合需要大量等待的程序,比如同时处理成千上万个连接的 Web 服务器。
use tokio::time::{sleep, Duration};
async fn fetch(name: &str, ms: u64) -> String { sleep(Duration::from_millis(ms)).await; // 假装在等网络 format!("{name}完成了")}
#[tokio::main]async fn main() { // 两个任务同时运行,所以总共大约 200ms,而不是 300ms。 let (a, b) = tokio::join!(fetch("第一个任务", 200), fetch("第二个任务", 100)); println!("{a}"); println!("{b}");}输出:
第一个任务完成了第二个任务完成了面试官喜欢听到的两个实在细节:Rust 的 future 是惰性的,不 await 就什么都不做;标准库不包含运行时,所以你得自己选一个。另外,在异步任务里写阻塞代码(比如一段很长的计算,或者 std::thread::sleep),会把同一个线程上的其他任务也一起卡住。
进一步学习:async/await、并发。
面试时的回答技巧
Section titled “面试时的回答技巧”- 先简单,再深入。 先给一句话的答案(“
Rc是一种引用计数指针,用于在单个线程内共享所有权”),如果对方看起来感兴趣,再补充细节。 - 讲清楚为什么。 Rust 的规则是为了防止特定的 bug 而存在的。说“借用规则防止了数据竞争和悬垂指针”,比单纯背出规则更有说服力。
- 用一个小例子。 写三行代码,或者描述一个例子,往往比长篇大论解释得更清楚。
- 坦诚面对取舍。 Rust 是有代价的:编译更慢、学习曲线更陡、错误处理要多写代码。承认这些体现的是经验,而不是弱点。
- 不知道就说“我不知道”,然后把你的推理过程说出来。“我没有直接用过
Pin,但我知道它和那些不能在内存中移动的 future 有关”就是一个很好的回答。 - 准备好一个故事。 面试官经常会问:“讲讲借用检查器拦住你的一次经历。”想一个你自己代码里的真实例子,以及你从中学到了什么。
- 通过写代码来练习。 练习题页面,以及猜数字游戏和待办事项命令行工具这两个项目,都是很好的热身。
小测验
1. 要在线程之间共享只读数据,你会用哪种智能指针?
2. ? 运算符遇到 Err 时会做什么?
3. 下面哪一项是安全的 Rust 能在编译时防止的?
4. 生命周期注解会改变一个值活多久吗?
得分: 0 / 4
- 《Rust 程序设计语言》简体中文版(The Rust Book):官方教程。这一页的大部分答案都直接来自书中关于所有权、trait、错误处理和并发的章节。
- 通过例子学 Rust(Rust by Example 中文版):简短的可运行示例,适合在面试前快速复习语法。
- The Rust Reference(英文):语言的精确规则,适合应对面试官那句“可是,具体是什么时候发生的?”
- The Rustonomicon(英文):
unsafeRust 的指南,深入讲解Send、Sync和内存布局,适合准备高级岗位的问题。 - Asynchronous Programming in Rust(英文):Rust 项目官方的 async 书,讲 future、
async/.await和运行时是怎样配合工作的。