Rust 常见疑问汇总

如何在特质里添加异步函数?

目前 Rust 不支持在特质里直接添加异步函数,但可以使用 async-trait 这个库来实现。这个库会将异步函数改写为返回 Pin<Box<dyn Future>> 的普通函数以绕过目前语言层面的限制,但也因此有堆分配以及动态分发这两个额外的代价,所以不会被直接添加到 Rust 语言中。

在特质里不支持使用异步函数是由于异步函数本质上是一个返回 impl Future<Output = T> 的函数,而目前 Rust 的类型系统还无法表达在特质的方法的返回类型上使用 impl Trait。有两个已经通过的 RFC 旨在解决这一问题:RFC 1598 泛型关联类型和 RFC 2071 impl Trait 存在类型,但它们的编译器支持还在实现中,实现进度可以参考 impl Trait 计划的页面。

编辑

如何同时等待多个 Future

如果想要等待多个 Future 都完成后返回,对于固定数量的 Future 可以使用 futures 所提供的 joinjoin3join4 等函数,或者 tokio 所提供的 join! 宏,将多个 Future 合并为一个进行等待。对于不定数量的 Future,比如有一个 Vec,则可以使用 futures 的 join_all 函数。

若要在数个 Future 中第一个错误发生时就返回,则可以使用它们对应的 try_jointry_join3try_join4try_join_all 等函数以及 try_join! 宏。

如果想要在多个 Future 中的第一个完成后就返回,可以使用 futures 的 selectselect_allselect_ok 函数或 tokio 的 select! 宏。

此外,futures 还提供了 FuturesOrderedFuturesUnordered 两个结构,它们将这些 Future 聚合成一个 Stream 逐个返回里面 Future 的结果。其中前者会按照输入的 Future 的顺序返回,而后者则是以任意顺序(可以近似看作按照完成顺序)返回。这两个结构额外提供了 push 方法来动态插入新的 Future,而且它们只会 poll 被唤醒的 Future,在 Future 数量较多时可能更高效。前面提到的 join_all 也会在一些情况下会自动使用 FuturesOrdered 来优化。

编辑

为什么 Rust 生成的程序体积比较大?如何最小化程序体积?

有多个因素使得 Rust 在默认情况下有着相对较大的程序体积,包括了单态化、调试符号、标准库等。一般来说,Rust 偏向于为性能优化而非更小的体积。

通常使用发布模式编译(--release),以及(在 Linux 和 macOS 下)使用 strip 删除符号信息可以在一定程度上缩小程序体积。更多方法可以参考 Minimizing Rust Binary Size,对这一问题有较完整的介绍。

编辑

写 Rust 有哪些常用的开发环境?

目前来看,最流行的 Rust 开发环境是 Visual Studio Code 配合 rust-analyzer,其次是在 CLion 或其他 IntelliJ 平台的 IDE 上安装 Intellij Rust 插件。使用 VimEmacs 进行开发的也不在少数,此外也有人使用其他编辑器。

Rust 群关于这一问题有定期调查:

编辑

为什么使用调试模式构建的 Rust 程序运行速度很慢?

当不指定任何标志时使用 cargo buildcargo run 来构建或运行 Rust 程序,会默认使用调试模式来进行构建,这时编译出的 Rust 程序运行速度很慢,有时甚至不及等效的 Python 代码。加上 --release 标志时会使用发布模式来构建,这样编译出的程序运行速度会快得多。造成这种现象的原因主要有两点:

一是调试模式构建主要是针对编译速度而非运行速度进行优化,因为在开发过程中我们常需要反复编辑、编译和调试,加速编译可以加快总体开发速度。但当为编译速度优化时,一方面,很多为加快运行速度但会拖慢编译的编译器优化会被禁用。由于 Rust 的库和程序通常有很多抽象设施,它们虽然给编写程序带来便利,但在调试模式下却可能无法被完全优化掉,进而拖慢运行速度。另一方面,调试模式下默认会启用一些额外的机制加快编译速度,如增量编译并发代码生成,这些优化虽然加快了编译速度,但同时也牺牲了编译结果的质量,降低了运行速度。

二是调试模式下会启用一些额外的检测,而这些检测会增加运行时间。在调试模式下,编译器默认会为内置整数类型的算数运算插入溢出检查,在溢出发生时 panic 以帮助发现潜在的逻辑问题,而在发布模式下则没有这一检查。此外还有提供如 debug_assert! 宏和 debug_assertions 条件编译选项等设施让库和应用可以根据需要在调试模式下额外执行一些较为昂贵的运行时检查。

编辑

错误处理推荐使用什么库?

目前一般认为对于应用程序推荐使用 anyhow,而对于库推荐使用 thiserror

anyhow 提供了一个基于特质对象的错误类型,可以很容易地将不同来源的错误统一到单一类型,并可以方便地为错误添加上下文,以及就地创建新的错误。

thiserror 则提供了一个 derive 宏,方便为自定义的错误类型实现 Error 特质

编辑

fn() 类型与 Fn() 等特质的关系和区别是什么?

在 Rust 中,每一个函数,无论是由 fn 关键字定义的一般函数,还是由闭包表达式定义的闭包,都有一个各自独立的匿名类型。为了能间接地使用函数,Rust 准备了两种方式,即 fn() 类型与 Fn()FnMut()FnOnce()特质

要表达不同的类型,最常见的方法即是使用特质(作为类型约束,即 T: Fn()impl Fn(),或者使用特质对象,即 dyn Fn()),Fn() 一族就是用于表达函数类型的特质。

fn() 不是一个特质,而是一个具体的类型,表示一个函数指针。功能上它与特质对象类似,可以近似地看作 &'static dyn Fn()。但 fn()Fn() 不同,它不包含对上下文的引用,因而只有一般函数或没有捕获任何上下文的闭包能够被转换成 fn()。因此它也与 &dyn Fn() 不同,不需要使用胖指针。它的大小与普通的指针一致。

因为 fn() 是一个函数指针,通过它调用函数与通过特质对象一样是间接调用,而使用 Fn() 等特质约束的泛型则是通过单态化来直接调用的。

编辑

格式化字符串的方法,如 printlninfo 等一般通过宏来实现,这是为什么?

因为 Rust 里函数不支持变长参数,因而如果要做编译期类型检查,就必须通过宏来实现。

编辑

Rust 的 Future 是基于轮询的,这种方式不会有性能问题吗?

Future 的轮询是带通知机制的轮询,与传统意义上的轮询不完全一样。

执行器调用 Futurepoll 方法时会传入一个 Waker,而 Future 可以将这个 Waker 保存起来,当自己的状态有所变化时,通过其通知执行器可以再次对自己进行轮询。通过这个机制,执行器可以避免反复轮询一个未准备好的 Future,避免了传统轮询带来的性能问题。

编辑

标准库的 Future、futures crate、tokio 和 async-std 等之间的关系是什么?

标准库的 Future 特质以及相关的 ContextPinWaker 等是核心。由于编译器编译异步函数需要依赖它们的定义,因而它们必须被包含在标准库里。

futuresFuture 的扩展,提供了许多虽不必进入标准库但依然重要的基础性的东西,比如 FutureExtStreamExt 等扩展特质和基础的通道执行器实现等。

tokioasync-std 是同一个层次的,主要提供异步运行时的实现,都依赖 futures 提供的元语,但因为处理的层次不同,所以可以看到一些自定义的与 futures 差不多的模块。

此外,虽然目前 Stream 是由 futures 提供的,但未来如果编译器要实现异步生成器,这个特质也很可能会进入标准库,因而对其的扩展也依然放进了独立的 StreamExt 里。

编辑

如何约束一个泛型参数为基本数值类型?

可以使用由 num-traits 所提供的 PrimIntFloat 两个特质来约束泛型参数为基本整数类型和浮点数类型。

编辑

如何直接在堆上分配新的对象或数组?

目前 Rust 语言本身没有提供稳定且不使用 unsafe 的方式能保证将一个对象或数组直接分配到堆上。

Box::new([0; 4096]) 等方式在语义上是在栈上创建数组,然后再移动到堆上。Vec 等容器类型的内容会直接分配在堆上,因而也可以通过 Vec::into_boxed_slice 从一个 Vec 得到堆上切片 Box<[T]>,再通过 TryFrom 获得堆上数组 Box<[T; N]>。不过添加每个元素从语义上依然是在栈上分配再移入容器的。

有一些第三方的库,如 copylessboxextdefault-boxed 等,通过依赖编译器优化或包装 unsafe 的功能来提供安全的接口进行直接分配。

使用 unsafe 的话可以通过调用 alloc 函数直接分配堆内存并取得指针,但需要手动初始化和管理分配的内存。Box 及其他智能指针类型未来很可能会提供 new_uninitnew_uninit_slice 等方法在堆上直接创建 MaybeUninit,但你仍将需要使用 unsafe 的方式来初始化其内容。

未稳定的 box 语法在一些情况下可以直接分配到堆上并创建一个 Box,但当有嵌套表达式,如 box Wrapper([0; 4096]),时则依然会有先分配在栈上再移入堆的问题。而且 box 语法目前也没有稳定化的计划。

此外,有一些提案,如 RFC 2884,试图提供新的接口来解决这一问题,但目前还没有足够的共识。

编辑

&'static TT: 'staticstatic 关键字分别表示什么?它们之间有什么联系?

&'static T

&'static T 表示的是一个指向类型为 T 的值的引用,它的生命周期'static。这里的生命周期 'static 在语义上表示这个引用所指向的值在程序的整个运行期间都不会被释放(但可以使用 unsafe 构造违反这一语义的情况)。

这样的引用最常来自于字面量和对字面量的引用(如字符串字面量 "hello world" 的类型为 &'static str&[1i32, 2, 3] 的类型为 &'static [i32; 3]),但它也可以来自于对常量或静态变量的引用,以及通过如 Vec::leak 等方法放弃所有权来获得。

由于 'static 生命周期长于任何其他生命周期,一个 &'static T 类型的引用可以安全地强制转换为一个标注为任何生命周期的引用 &'a T

T: 'static

T: 'static 表示的是一个类型约束,它表明类型 T 可以在程序的整个运行期间有效,也即 T 中不包含引用,或包含的所有引用的生命周期都为 'static,举例来说:

如果一个值的类型不满足 T: 'static,则这个值必须在类型上所标注的生命周期结束之前被释放掉。如果满足,则这个值可以存在任意长时间。显然一个值的生命周期不能超越约束其类型的生命周期,因此任何 &'static T 引用中的 T 必然要满足 T: 'static

static 关键字

static 关键字声明的,形如 static FOO: [i32; 5] = [1, 2, 3, 4, 5]; 的变量是静态变量

一个静态变量在程序的整个运行期间是唯一的,有唯一的地址,而且不会被释放。它可以被看作是 Rust 里的全局变量。显然如果一个静态变量的类型为 T,这个类型必须满足 T: 'static,如此一来,这个类型的引用才是 &'static T

编辑

如果有一个 trait Foo: Base,如何将一个 &dyn Foo 转换到 &dyn Base

Rust 目前不直接提供这种转换,如果需要转换可以使用一个中间特质来实现,如

trait Base {
    // ...
}

trait AsBase {
    fn as_base(&self) -> &dyn Base;
}

impl<T: Base> AsBase for T {
    fn as_base(&self) -> &dyn Base { self }
}

trait Foo: AsBase {
    // ...
}

不支持的主要原因是在特质对象虚表中没有相应的数据指向另一个特质的虚表,而不提供相应数据的原因可能是由于这很容易产生过多无用的虚表,进而导致二进制体积的膨胀。

更多关于这一话题的讨论可以参考 RFC 2765 以及 Traits, dynamic dispatch and upcasting

编辑

新创建的空 Vec<T> 的指针为何指向1248等地址?

Vec 的容量为0时,没有合法的操作会向其指针指向的位置进行读取和写入,进行任何读写之前都必然会有一次内存分配,因此这个初始的指针并不需要是一个有效的指针。这也使得创建 Vec 本身没有进行实际内存分配的必要,既省去了内存分配的开销,也让创建容器的操作可以在常量上下文中使用。

而因为 Vec 需要能被作为切片使用,由于切片对数据指针的要求,它的指针的地址需要是非空并且正确对齐的,因而简单起见便选择了类型的对齐的大小作为这个无效指针指向的地址。

编辑

有什么 crate 可以…?在哪找到它们?

有很多 crates 列表,这里是其中的一些:

编辑
+ 贡献新条目