穿越说明:本文发布于2025年6月22日。Rust 1.80预计于2025年7月正式发布,本文基于Rust 1.80 beta版本及之前稳定版的特性撰写,最终Rust 1.80稳定版的特性可能会有差异,请以官方发布信息为准。

Rust 1.80+面试题:我被问到的那些问题

最近我面试了几家招Rust开发的公司,被问到了很多Rust相关的问题。有些问题很基础,有些问题很深入,有些问题我答得很好,有些问题我答不上来。

这篇文章我想整理一下面试中被问到的高频Rust面试题,以及我总结的答案和思路。从所有权、借用、生命周期到并发、异步、unsafe,覆盖Rust面试的各个方面。如果你也在准备Rust面试,希望这篇文章能给你一些参考。

先说明一下,我面试的岗位主要是后端开发和系统编程,用到Rust做高性能服务和底层工具。不同的岗位面试的侧重点可能不一样,比如做区块链的会更关注零成本抽象和WASM,做嵌入式的会更关注unsafe和内存管理。这篇文章主要覆盖通用的Rust面试题。

一、基础概念题

1. Rust的所有权规则是什么?

这是Rust面试最基础也是最常问的问题。

Rust的所有权规则有三条:

第一,Rust中每个值都有一个所有者(owner)。

第二,每个值在同一时间只能有一个所有者。

第三,当所有者离开作用域时,这个值会被丢弃(drop)。

所有权是Rust最核心的概念,也是Rust能在没有垃圾回收的情况下保证内存安全的关键。理解了所有权,才能理解Rust的其他概念,比如借用、生命周期、移动语义。

面试的时候,不仅要能说出这三条规则,还要能解释为什么需要所有权,所有权解决了什么问题。所有权解决的是内存管理的问题,通过在编译期检查所有权,保证内存安全,不需要运行时的垃圾回收。

2. 什么是移动语义?什么是Copy trait?

移动语义是Rust中一个重要的概念。

在Rust中,当你把一个变量赋值给另一个变量,或者把变量传给函数的时候,默认是移动(move),而不是拷贝。移动之后,原来的变量就不能再使用了,所有权转移到了新的变量。

比如:

let s1 = String::from("hello");
let s2 = s1; // s1的所有权移动到了s2
// println!("{}", s1); // 这里会报错,因为s1已经不再有效

但有些类型实现了Copy trait,赋值的时候会自动拷贝,而不是移动。Copy类型的变量赋值之后,原来的变量还能继续使用。

实现Copy trait的类型包括:所有整数类型、浮点类型、布尔类型、字符类型、只包含Copy类型的元组和数组。

String、Vec、HashMap这些动态大小的类型没有实现Copy,因为它们的内存是在堆上分配的,拷贝的成本比较高。

面试的时候要能解释为什么有的类型是Copy有的不是,以及移动语义和Copy trait的区别。

3. 什么是借用?可变借用和不可变借用的规则是什么?

借用(borrowing)是Rust中另一个核心概念。

很多时候,我们不需要获取值的所有权,只需要临时使用一下。这时候就可以用借用,也就是引用。

Rust的借用规则:

第一,在同一时间,你可以有任意多个不可变借用(&T)。

第二,在同一时间,你只能有一个可变借用(&mut T)。

第三,借用必须总是有效的,也就是不能出现悬垂引用(dangling reference)。

这两条规则(可变借用和不可变借用不能同时存在,可变借用只能有一个)保证了Rust在编译期就能避免数据竞争,不需要运行时的检查。

面试的时候,不仅要能说出规则,还要能解释为什么要有这些规则。这些规则是为了保证内存安全和线程安全,避免数据竞争和悬垂引用。

4. 什么是生命周期?为什么需要生命周期标注?

生命周期(lifetime)是Rust中比较难理解的概念,也是面试常问的问题。

生命周期是引用的有效范围。Rust编译器通过生命周期检查,保证引用总是有效的,不会出现悬垂引用。

大部分时候,Rust编译器能自动推断生命周期,不需要我们手动标注。但在某些情况下,编译器无法推断,需要我们手动标注生命周期。

需要手动标注生命周期的情况主要有:

第一,函数返回引用的时候。如果函数的参数有多个引用,返回的引用可能来自任何一个参数,编译器无法确定,需要标注。

第二,结构体中包含引用的时候。结构体中的引用需要标注生命周期,告诉编译器这个引用的有效范围。

第三,trait对象和trait方法中的引用。

生命周期标注的语法是'a,比如&'a T表示一个生命周期为'a的引用。

面试的时候,要能解释什么是生命周期,为什么需要生命周期,以及什么情况下需要手动标注生命周期。生命周期是Rust独有的概念,也是Rust能保证内存安全的关键之一。

5. Rust中的trait是什么?和接口有什么区别?

trait是Rust中定义共享行为的方式,类似于其他语言中的接口。

trait定义了一组方法签名,类型可以实现这个trait,提供具体的方法实现。

trait和接口的区别主要有:

第一,trait可以提供默认实现。接口通常不能提供默认实现(Java 8之后接口也可以有默认方法了)。

第二,trait可以为外部类型实现。你可以为任何类型实现trait,包括标准库中的类型和其他库中的类型,只要trait是你定义的。这是孤儿规则(orphan rule)允许的。

第三,trait可以用作参数类型和返回类型,有两种方式:静态分发(impl Trait)和动态分发(dyn Trait)。

第四,trait可以有泛型参数和关联类型。

面试的时候,要能解释trait的概念,以及trait和接口的区别。trait是Rust中实现多态和代码复用的主要方式。

6. 什么是静态分发和动态分发?有什么区别?

静态分发(static dispatch)和动态分发(dynamic dispatch)是Rust中实现多态的两种方式。

静态分发是在编译期确定调用哪个方法,通过泛型和impl Trait实现。静态分发没有运行时开销,性能好,因为编译器会为每个具体类型生成专门的代码(单态化)。

动态分发是在运行时确定调用哪个方法,通过dyn Trait实现。动态分发有运行时开销,因为需要通过虚函数表(vtable)来查找方法。但动态分发更灵活,可以在运行时存储不同的类型。

静态分发的例子:

fn foo<T: MyTrait>(x: T) {
    x.method();
}

动态分发的例子:

fn foo(x: &dyn MyTrait) {
    x.method();
}

面试的时候,要能解释静态分发和动态分发的概念、区别和各自的优缺点,以及什么时候用静态分发什么时候用动态分发。

二、内存管理题

7. Box、Rc、Arc、Cell、RefCell分别是什么?什么时候用?

这是Rust面试非常高频的问题,这几个智能指针是Rust内存管理的核心。

第一,Box<T>。Box是最简单的智能指针,把值分配在堆上,栈上存一个指向堆的指针。Box用于把动态大小类型(DST)变成固定大小,或者用于递归类型(比如链表、树),或者用于把大的值移到堆上避免拷贝。Box在编译期检查所有权,没有运行时开销。

第二,Rc<T>。Rc是引用计数指针,允许多个所有者共享同一个值。Rc用引用计数来管理内存,当最后一个Rc被销毁时,值才会被销毁。Rc是单线程的,不能跨线程使用。Rc在编译期检查所有权,但引用计数是运行时维护的,有一点运行时开销。

第三,Arc<T>。Arc是原子引用计数指针,和Rc类似,但可以跨线程使用。Arc用原子操作来维护引用计数,线程安全。Arc的开销比Rc大一点,因为原子操作比普通操作慢。

第四,Cell<T>。Cell提供内部可变性(interior mutability),允许在不可变引用的情况下修改值。Cell只能用于Copy类型,通过get和set方法来读写值。Cell没有运行时开销,因为它只是把值包了一层,修改的时候直接替换。

第五,RefCell<T>。RefCell也提供内部可变性,但可以用于非Copy类型。RefCell在运行时检查借用规则,如果违反了借用规则(比如同时有可变借用和不可变借用),会panic。RefCell有运行时开销,因为需要维护借用计数。

面试的时候,要能解释每个智能指针的作用、适用场景、开销,以及它们之间的区别。这几个指针是Rust内存管理的核心,也是面试必问的。

8. 什么是内部可变性?为什么需要Cell和RefCell?

内部可变性(interior mutability)是Rust中一个重要的概念。

通常情况下,Rust的借用规则要求:要么有一个可变借用,要么有多个不可变借用,不能同时有可变和不可变借用。但有些情况下,我们需要在看起来不可变的情况下修改值,这时候就需要内部可变性。

内部可变性的典型使用场景:

第一,trait对象。trait的方法签名是固定的,如果trait方法是&self,但实现的时候需要修改self,这时候就需要内部可变性。

第二,共享所有权。当用Rc或Arc共享一个值的时候,通常只能拿到不可变引用。如果需要修改共享的值,就需要内部可变性,比如Rc<RefCell<T>>。

第三,懒初始化。有些值需要延迟初始化,但对外暴露的是不可变接口,这时候可以用内部可变性。

Cell和RefCell就是Rust提供的内部可变性类型。Cell用于Copy类型,没有运行时开销;RefCell用于任意类型,有运行时借用检查。

面试的时候,要能解释什么是内部可变性,为什么需要内部可变性,以及Cell和RefCell的区别和适用场景。

9. Rust的Drop trait是什么?析构的顺序是什么?

Drop trait用于定义值被销毁时的行为,类似于其他语言中的析构函数。

Drop trait只有一个方法drop,当值离开作用域的时候,编译器会自动调用drop方法。

Drop trait的典型使用场景:释放资源,比如关闭文件、释放网络连接、释放GPU资源、解锁互斥锁。

析构的顺序:

第一,局部变量按照声明的相反顺序析构。后声明的先析构,先声明的后析构。

第二,结构体的字段按照声明的顺序析构。先声明的字段先析构,后声明的字段后析构。

第三,元组的元素按照从左到右的顺序析构。

第四,数组的元素按照从左到右的顺序析构。

面试的时候,要能解释Drop trait的作用,以及析构的顺序。Drop是Rust中资源管理的重要机制,也是RAII模式的基础。

10. 什么是RAII?Rust中怎么实现RAII?

RAII(Resource Acquisition Is Initialization,资源获取即初始化)是一种编程模式,把资源的获取和对象的初始化绑定在一起,把资源的释放和对象的销毁绑定在一起。

RAII的核心思想是:用对象来管理资源,对象创建的时候获取资源,对象销毁的时候释放资源。这样就不需要手动释放资源,也不会因为忘记释放而导致资源泄漏。

Rust通过所有权和Drop trait天然支持RAII。比如:

  • File对象,打开文件的时候获取文件描述符,销毁的时候自动关闭文件。
  • MutexGuard对象,获取锁的时候加锁,销毁的时候自动解锁。
  • Vec对象,创建的时候分配内存,销毁的时候自动释放内存。

RAII是Rust中资源管理的基础模式,保证了资源的安全释放,不需要手动管理,也不会泄漏。

面试的时候,要能解释RAII的概念,以及Rust中怎么通过所有权和Drop实现RAII。RAII是Rust区别于很多其他语言的重要特性。

三、并发和异步题

11. Rust的并发模型是什么?怎么保证线程安全?

Rust的并发模型是基于所有权和类型系统的,在编译期保证线程安全,不需要运行时的检查。

Rust通过两个trait来标记类型的线程安全特性:

第一,Send trait。实现了Send的类型,可以安全地在线程之间转移所有权。大部分类型都是Send,除了Rc、裸指针等。

第二,Sync trait。实现了Sync的类型,可以安全地在线程之间共享不可变引用。也就是多个线程同时持有&T是安全的。大部分类型都是Sync,除了Cell、RefCell、Rc等。

这两个trait是auto trait,编译器会自动为类型实现,除非类型包含不满足条件的字段。也可以手动实现或不实现(unsafe impl)。

Rust的标准库提供了很多并发原语,比如线程(thread)、互斥锁(Mutex)、读写锁(RwLock)、条件变量(Condvar)、通道(channel)、原子类型(atomic)、屏障(Barrier)。

Rust的并发模型的优势是:在编译期就能发现数据竞争和线程安全问题,不需要运行时的检查,也不会有运行时的开销。这是Rust在系统编程和高性能服务领域受欢迎的重要原因。

面试的时候,要能解释Rust的并发模型,Send和Sync的概念,以及Rust怎么在编译期保证线程安全。

12. Rust的异步编程模型是什么?async/await是怎么工作的?

Rust的异步编程是基于Future trait的,async/await是语法糖。

Future trait代表一个异步计算,它有一个poll方法,返回Poll::Ready(计算完成)或Poll::Pending(还没完成)。

async函数会返回一个实现了Future的类型。在async函数中使用.await,会等待另一个Future完成。

async/await的工作原理:

第一,async函数被编译器转换成一个状态机,每个.await点是一个状态。

第二,调用async函数的时候,不会立即执行,而是返回一个Future。

第三,Future需要被执行器(executor)poll才会执行。执行器调用Future的poll方法,如果返回Pending,执行器会注册一个waker,等Future就绪的时候再poll。

第四,.await点会检查Future的状态,如果是Ready就继续执行,如果是Pending就返回Pending,等下次poll的时候从这个状态继续。

Rust的异步运行时(比如tokio、async-std)提供了执行器、反应器、任务调度等功能。标准库只定义了Future trait和基本的异步原语,异步运行时是第三方库提供的。

面试的时候,要能解释Rust的异步编程模型,Future trait,async/await的工作原理,以及异步运行时的作用。异步是现在Rust后端开发的必备技能,也是面试的重点。

13. 什么是Pin?为什么异步需要Pin?

Pin是Rust中一个比较难理解的概念,也是异步编程中经常用到的。

Pin是一个智能指针,把值固定在内存中的某个位置,不能移动。Pin的作用是保证自引用结构体(self-referential struct)的安全。

自引用结构体是指结构体中包含一个指向自己字段的引用。比如:

struct SelfRef {
    data: String,
    ptr: *const String, // 指向data
}

如果这个结构体被移动了,data的地址变了,但ptr还是指向原来的地址,就会出现悬垂指针。Pin就是用来解决这个问题的,把结构体固定在内存中,不能移动,这样ptr就一直有效。

异步编程中需要Pin,因为async函数转换成的状态机可能是自引用的。比如,在async函数中,一个.await点引用了前面创建的局部变量,状态机中就会有自引用。所以Future的poll方法的self参数是Pin<&mut Self>,保证Future不会被移动。

面试的时候,要能解释Pin的概念,为什么需要Pin,以及异步中Pin的作用。Pin是Rust中比较高级的概念,也是面试的难点。

14. 什么是waker?异步运行时怎么知道Future什么时候就绪?

waker是Rust异步编程中的一个重要概念。

当Future被poll的时候,如果返回Pending,说明Future还没就绪。这时候,执行器需要知道什么时候Future就绪了,才能再次poll。这就是waker的作用。

waker是一个Waker类型,包含一个vtable,有wake和wakebyref方法。当Future就绪的时候,调用waker.wake(),通知执行器这个Future可以再次poll了。

具体的工作流程:

第一,执行器poll Future的时候,把waker传给Future(通过Context参数)。

第二,Future把waker存起来,注册到事件源上(比如IO事件、定时器)。

第三,当事件发生的时候(比如IO就绪、定时器到期),事件源调用waker.wake()。

第四,执行器收到wake通知,把对应的Future放到就绪队列中,再次poll。

waker是异步运行时的核心机制之一,连接了事件源和执行器,让异步编程高效运行。

面试的时候,要能解释waker的概念和工作原理。waker是Rust异步编程中比较底层的概念,理解了waker才能真正理解Rust的异步运行时。

四、unsafe和高级特性题

15. 什么时候需要用unsafe?unsafe能做什么?

unsafe是Rust中的一个重要概念,用于绕过编译器的安全检查,执行一些编译器无法保证安全的操作。

需要用unsafe的场景主要有:

第一,解引用裸指针。裸指针(const T和mut T)不受借用检查器的约束,解引用需要unsafe。

第二,调用unsafe函数或unsafe方法。有些函数是unsafe的,比如直接操作内存的函数、FFI函数,调用需要unsafe。

第三,实现unsafe trait。有些trait是unsafe的,比如Send、Sync,实现需要unsafe,因为编译器无法保证实现是安全的。

第四,访问或修改可变静态变量。可变静态变量(static mut)的访问需要unsafe,因为可能有数据竞争。

第五,访问union的字段。union的字段访问需要unsafe,因为编译器无法保证访问的字段是有效的。

unsafe能做的事情,就是绕过编译器的安全检查,直接操作内存和硬件。unsafe不是说代码一定不安全,而是说编译器无法保证安全,需要程序员自己保证。

使用unsafe的时候要注意:

第一,尽量少用unsafe,能用安全Rust就用安全Rust。

第二,unsafe块要尽量小,只把需要unsafe的操作包起来。

第三,unsafe代码要仔细审查,确保安全。

第四,unsafe代码要有注释,说明为什么需要unsafe,以及怎么保证安全。

面试的时候,要能解释什么时候需要unsafe,unsafe能做什么,以及使用unsafe的注意事项。unsafe是Rust系统编程的必备技能,也是面试的重点。

16. Rust的零成本抽象是什么意思?

零成本抽象(zero-cost abstraction)是Rust的设计哲学之一,也是C++的设计哲学。

零成本抽象的意思是:高级抽象(比如泛型、trait、迭代器、async/await)不会带来运行时的开销,和手写底层代码的性能一样。

Rust实现零成本抽象的方式:

第一,泛型单态化(monomorphization)。泛型函数在编译的时候会为每个具体类型生成专门的版本,没有运行时的类型查找开销。

第二,迭代器优化。Rust的迭代器是零成本的,编译器会把迭代器链优化成高效的循环,和手写循环一样快,甚至更快。

第三,trait静态分发。用impl Trait和泛型的时候是静态分发,没有运行时开销。只有用dyn Trait的时候才有动态分发的开销。

第四,async/await状态机。async函数被转换成状态机,没有运行时的协程调度开销(除了异步运行时本身的开销)。

零成本抽象让我们可以用高级的、可读性好的抽象来写代码,同时不牺牲性能。这是Rust在系统编程领域受欢迎的重要原因。

面试的时候,要能解释零成本抽象的概念,以及Rust怎么实现零成本抽象。零成本抽象是Rust的核心设计哲学之一。

17. Rust的错误处理方式是什么?和异常有什么区别?

Rust的错误处理是基于Result和Option的,没有异常机制。

Result<T, E>表示可能成功也可能失败的操作,Ok(T)表示成功,Err(E)表示失败。Option<T>表示可能有值也可能没有值,Some(T)表示有值,None表示没有值。

Rust的错误处理方式:

第一,用match或if let来处理Result和Option。

第二,用?运算符来传播错误。?运算符在Result是Err的时候直接返回Err,是Ok的时候解包。

第三,用panic!宏来处理不可恢复的错误。panic会导致线程崩溃(或者整个程序崩溃)。

Rust的错误处理和异常的区别:

第一,Rust的错误是显式的,函数签名中就能看出会不会返回错误。异常是隐式的,函数签名看不出会不会抛异常。

第二,Rust的错误处理在编译期检查,编译器会强制你处理Result(或者用?传播)。异常是运行时的,可能会被忽略。

第三,Rust的错误处理没有栈展开的开销(除非panic)。异常有栈展开的开销。

第四,Rust的错误类型可以是任意类型,灵活。异常通常需要继承Exception类。

Rust的错误处理方式更安全、更显式,能在编译期发现很多错误处理的问题。但写起来可能比异常繁琐一点,不过?运算符大大简化了错误传播。

面试的时候,要能解释Rust的错误处理方式,以及和异常的区别。错误处理是Rust的重要特性,也是面试常问的。

五、面试准备建议

最后,给准备Rust面试的朋友一些建议。

第一,打好基础。Rust的核心概念一定要理解透彻,比如所有权、借用、生命周期、trait、智能指针。这些是面试的基础,也是日常开发的基础。

第二,多写代码。光看书看文档是不够的,一定要多写代码。可以做一些小项目,比如命令行工具、Web服务、算法题。在写代码的过程中加深对Rust的理解。

第三,读标准库源码。Rust的标准库写得非常好,是学习Rust的最佳材料。可以读一些核心类型的源码,比如Vec、String、HashMap、Option、Result、智能指针。理解了标准库的实现,对Rust的理解会深入很多。

第四,了解异步。现在Rust后端开发基本上都要用异步,tokio是事实上的标准。要了解Future、async/await、Pin、waker这些概念,以及tokio的使用。

第五,准备项目经验。面试一定会问项目经验,要准备一两个能讲清楚的Rust项目,包括项目的架构、技术选型、遇到的问题、怎么解决的、有什么收获。

第六,刷算法题。Rust面试也会问算法题,要熟悉用Rust写常见的数据结构和算法。可以在LeetCode上用Rust刷题。

第七,了解Rust的生态。了解Rust常用的库和框架,比如Web后端的axum、actix-web,序列化的serde,异步运行时的tokio,数据库的sqlx、diesel,错误处理的thiserror、anyhow。了解生态能让你在面试中更有底气。

写在最后

Rust是一门很有魅力的语言,它的所有权系统、零成本抽象、内存安全、并发安全,都让人印象深刻。但Rust的学习曲线也比较陡,需要花时间和精力去理解那些独特的概念。

面试是检验学习成果的好方式,也是查漏补缺的好机会。通过面试,你能发现自己哪些地方掌握得好,哪些地方还需要加强。

这篇文章整理了我面试中遇到的高频Rust面试题,希望能给准备Rust面试的朋友一些参考。当然,不同的公司、不同的岗位,面试的侧重点不一样,这篇文章只是一个通用的参考。

最后,祝所有准备Rust面试的朋友都能拿到心仪的offer。

Rust之路,道阻且长,行则将至。