02-用户体验

用户体验 — Pragmatic Rust Guidelines

译文 · 基于 Pragmatic Rust Guidelines

原文链接: https://microsoft.github.io/rust-guidelines/guidelines/libs/ux/index.html

用户体验

抽象不要明显嵌套 (M-SIMPLE-ABSTRACTIONS)

本条守护:低认知负荷与良好的开箱即用 UX。

设计公共类型与主要 API 面时,避免向用户暴露嵌套或复杂的参数化类型。

类型参数虽然强大,但会引入认知负荷;若涉及的 trait 还是 crate 专有的,负担更重。类型参数会传染到在字段中持有这些类型的用户代码,自身往往还带有复杂的 trait 层次,并可能造成令人困惑的错误信息。

从正在编写 Foo 的用户视角来看,其中其他结构体来自你的 crate:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
struct Foo {
    service: Service // 很好
    service: Service<Backend> // 可接受
    service: Service<Backend<Store>> // 不好

    list: List<Rc<u32>> // 很好,`List<T>` 是简单容器,
                        // 其他类型由用户提供。

    matrix: Matrix4x4 // 很好
    matrix: Matrix4x4<f32> // 仍可接受
    matrix: Matrix<f32, Const<4>, Const<4>, ArrayStorage<f32, 4, 4>> // ?!?
}

可见的类型参数应在服务类类型中避免(即主要按线程 / 应用实例化一次、并经常作为依赖传递的类型),尤其当被嵌套者与该服务来自同一 crate 时。

容器、智能指针及类似数据结构显然必须暴露类型参数,例如上面的 List<T>。即便如此,也应注意限制参数的数量与嵌套。

要判断是否应避免类型参数嵌套,请考虑这些因素:

  • 该类型会被用户命名吗?
    • 服务级类型总是预期会被命名(例如 Library<T>),
    • 工具类型,例如众多 std::iter 类型如 Chain、Cloned、Cycle,则不 预期会被命名。
  • 该类型是否主要与非用户类型组合?
  • 所用类型参数是否有复杂约束?
  • 所用类型参数是否会影响其他类型或函数中的推断?

符合的因素越多,认知负担越大。

作为经验法则,主要的服务 API 类型不应主动嵌套;若确实要嵌套,也只应嵌套 1 层。换句话说,这些 API 不应迫使使用者去面对 Foo<Bar<FooBar>>。不过,若 Foo<T> 的用户想把自己的 A<B<C>> 作为 T 传入,则应当可以这样做。

💡 用类型魔法提升 UX?

上述指南针对的是你在正常开发活动中可能创建的日常主力类型。其意图是 减少用户使用你的代码时遇到的摩擦。

然而,在设计整体 API 模式与生态时,有时有正当理由引入精巧的类型魔法,以从总体上降低 其中的认知摩擦,Bevy 的 ECS 或 Axum 的请求处理器便是例子。

不过,这种做法的回报门槛很高。若你对这种创造性使用泛型的效用有任何怀疑,用户可能 没有它们会更好。

API 中避免智能指针和包装器 (M-AVOID-WRAPPERS)

本条守护:低认知负荷与符合人体工学的 API。

作为 M-ABSTRACTIONS-DONT-NEST 的特化,泛型包装器与智能指针如 Rc<T>、Arc<T>、Box<T> 或 RefCell<T> 应在公共 API 中避免。

从用户视角看,这些大多是实现细节,并引入用户必须自行解决的传染性复杂度。事实上,一旦多个 crate 对所需包装器类型意见不一,这些问题甚至可能无法解决。

若内部需要包装器,应隐藏在使用 &T、&mut T 或 T 等简单类型的干净 API 之后。对比:

1
2
3
4
5
6
7
// 好:简洁的 API
pub fn process_data(data: &Data) -> State { ... }
pub fn store_config(config: Config) -> Result<(), Error> { ... }

// 不好:暴露实现细节
pub fn process_shared(data: Arc<Mutex<Shared>>) -> Box<Processed> { ... }
pub fn initialize(config: Rc<RefCell<Config>>) -> Arc<Server> { ... }

API 中使用智能指针在以下情况可以接受:

  • 智能指针是该 API 目的的核心(例如,一个新的容器库)

  • 基于基准测试,智能指针显著提升性能,且复杂度是合理的。

优先具体类型,其次泛型,再次 dyn trait (M-DI-HIERARCHY)

本条守护:可组合的模式,并避免设计锁定。

在需要异步依赖时,优先具体类型而非泛型,优先泛型而非 dyn Trait。

从像 C# 这样重度依赖接口的语言移植代码时,很容易意外偏离这一模式。 设想你正在把一个名为 Database 的服务从 C# 移植到 Rust,并受原先 IDatabase 接口启发,天真地翻译成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
trait Database {
    async fn update_config(&self, file: PathBuf);
    async fn store_object(&self, id: Id, obj: Object);
    async fn load_object(&self, id: Id) -> Object;
}

impl Database for MyDatabase { ... }

// 预期这样使用:
async fn start_service(b: Rc<dyn Database>) { ... }

除了读起来不地道,这种方法还会排除与对象安全冲突的其他 Rust 构造, 可能给异步代码带来问题,并暴露包装器(参见 M-AVOID-WRAPPERS)。

相反,当需要不止一种实现时,应遵循此设计升级阶梯:

若另一种实现只关心提供用于测试的 sans-io 实现,则把你的类型实现为 枚举,转而遵循 M-MOCKABLE-SYSCALLS。

若预期用户提供自定义实现,你应引入一个或多个 trait,并在自己类型的 固有函数之上为它们实现。每个 trait 应相对狭窄,例如 StoreObject、LoadObject。若最终需要单个 trait,应做成子 trait,例如 trait DataAccess: StoreObject + LoadObject {}。

使用这些 trait 的代码理想情况下应把它们作为泛型类型参数接受,只要其使用不会造成显著嵌套 (参见 M-ABSTRACTIONS-DONT-NEST)。

1
2
3
4
5
6
7
// 好,泛型不会产生传染性影响,且只使用最具体的 trait
async fn read_database(x: impl LoadObject) { ... }

// 可接受,除非进一步嵌套变得过度。
struct MyService<T: DataAccess> {
    db: T,
}

一旦泛型成为嵌套问题,可以考虑 dyn Trait。即便如此,也应避免可见的包装,并优先使用自定义包装器。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# use std::sync::Arc;
# trait DataAccess {
#     fn foo(&self);
# }
// 这使你可以稍后扩展或更改 `DynamicDataAccess`。若需要,你也可以
// 为 `DynamicDataAccess` 实现 `DataAccess`,并把它用于
// 常规泛型函数。
struct DynamicDataAccess(Arc<dyn DataAccess>);

impl DynamicDataAccess {
    fn new<T: DataAccess + 'static>(db: T) -> Self {
        Self(Arc::new(db))
    }
}

struct MyService {
    db: DynamicDataAccess,
}

泛型包装器也可以与 M-MOCKABLE-SYSCALLS 中的枚举做法结合:

1
2
3
4
5
6
7
enum DataAccess {
    MyDatabase(MyDatabase),
    Mock(mock::MockCtrl),
    Dynamic(DynamicDataAccess)
}

async fn read_database(x: &DataAccess) { ... }

错误是规范结构体 (M-ERRORS-CANONICAL-STRUCTS)

本条守护:统一的错误类型与一致的错误处理。

错误应是针对具体场景的 struct,其中包含 Backtrace、 可能的上游错误原因,以及辅助方法。

简单 crate 通常暴露单一错误类型 Error,复杂 crate 可能暴露多种类型,例如 AccessError 和 ConfigurationError。错误类型应提供辅助方法,给出额外信息以便调用者处理该错误。

一个简单错误可能如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# use std::backtrace::Backtrace;
# use std::fmt::Display;
# use std::fmt::Formatter;
pub struct ConfigurationError {
    backtrace: Backtrace,
}

impl ConfigurationError {
    pub(crate) fn new() -> Self {
        Self { backtrace: Backtrace::capture() }
    }
}

// 实现 Debug + Display

在适当情况下,错误类型应提供上下文错误信息,例如:

1
2
3
4
5
6
7
8
# use std::backtrace::Backtrace;
# #[derive(Debug)]
# pub struct ConfigurationError {
#    backtrace: Backtrace,
# }
impl ConfigurationError {
    pub fn config_file(&self) -> &Path { }
}

若你的 API 做混合操作,或依赖多种上游库,则存储一个 ErrorKind。 ErrorKind,以及更一般的基于枚举的错误,不应被用来避免创建单独的公共错误类型——若原本并不存在错误重叠:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 优先这样
fn download_iso() -> Result<(), DownloadError> {}
fn start_vm() -> Result<(), VmError> {}

// 而不是这样
fn download_iso() -> Result<(), GlobalEverythingErrorEnum> {}
fn start_vm() -> Result<(), GlobalEverythingErrorEnum> {}

// 不过,并非每个函数都值得一个新错误类型。错误
// 应足够通用以便复用。
fn parse_json() -> Result<(), ParseError> {}
fn parse_toml() -> Result<(), ParseError> {}

若你确实使用内部 ErrorKind,出于面向未来演进的原因,该枚举不应直接暴露, 否则你会把所有可能的失败模式都暴露给调用者,包括那些你认为内部且无法处理的。 相反,如下所示暴露各种 is_xxx() 方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# use std::backtrace::Backtrace;
# use std::fmt::Display;
# use std::fmt::Formatter;
#[derive(Debug)]
pub(crate) enum ErrorKind {
    Io(std::io::Error),
    Protocol
}

#[derive(Debug)]
pub struct HttpError {
    kind: ErrorKind,
    backtrace: Backtrace,
}

impl HttpError {
    pub fn is_io(&self) -> bool { matches!(self.kind, ErrorKind::Io(_)) }
    pub fn is_protocol(&self) -> bool { matches!(self.kind, ErrorKind::Protocol) }
}

大多数上游错误不提供回溯。你应在创建 Error 实例时捕获一份,要么通过你的 某种 Error::new() 变体,要么在实现 From<UpstreamError> for Error {} 时。

错误结构体必须正确实现 Display,其渲染方式如下:

1
2
3
4
5
6
7
8
impl Display for MyError {
    // 打印一句摘要,说明发生了什么。
    // 打印 `self.backtrace`。
    // 打印你可能拥有的任何额外上游 'cause' 信息。
#   fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
#       todo!()
#   }
}

错误还必须实现 std::error::Error:

1
impl std::error::Error for MyError { }

最后,若你的 crate 会发出大量错误,可考虑创建一个私有的 bail!() 辅助宏来简化错误实例化。

💡 何时能拿到回溯

回溯在复杂或异步代码中是宝贵的调试工具,因为错误可能在被浮出之前,沿着调用栈传播很远。

话虽如此,它们是开发工具,不是运行时诊断;默认情况下 Backtrace::capture() 不会捕获 回溯,因为开销很大,例如在作者电脑上每次捕获约 4μs。

相反,Rust 会评估一组环境变量,例如 RUST_BACKTRACE,并且只在被明确要求时才遍历调用帧。否则它捕获一条空回溯,代价仅为几条 CPU 指令。

规范错误转换使用 From 而非 map_err (M-FROM-ERROR)

本条守护:地道的错误处理。

凡是由你拥有的 Error 类型,应 impl From<Other> for Error {},而不是在代码各处通过 .map_error() 处理转换。调用 .map_error() 仅在处理外部错误类型,或需要保留上下文信息时才合适。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 不好,在每个调用点重复相同转换,并掩盖了成功路径。
fn load() -> Result<Config, MyError> {
    let bytes = read("config.toml").map_err(|e| MyError::Io(e))?;
    let text = str::from_utf8(&bytes).map_err(|e| MyError::Utf8(e))?;
    let cfg = toml::from_str(text).map_err(|e| MyError::Parse(e))?;
    Ok(cfg)
}

// 好,只定义一次转换,让 `?` 来应用它。
impl From<std::io::Error> for MyError { ... }
impl From<std::str::Utf8Error> for MyError { ... }
impl From<toml::de::Error> for MyError { ... }

fn load() -> Result<Config, MyError> {
    let bytes = read("config.toml")?;
    let text = str::from_utf8(&bytes)?;
    let cfg = toml::from_str(text)?;
    Ok(cfg)
}

复杂类型构造使用 builder (M-INIT-BUILDER)

本条守护:复杂场景下面向未来的类型构造。

可能支持 4 种或更多任意初始化排列组合的类型应提供 builder。换句话说,最多 2 个可选初始化参数的类型可通过固有方法构造:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# struct A;
# struct B;
struct Foo;

// 支持 2 个可选构造参数,用固有方法即可。
impl Foo {
    pub fn new() -> Self { Self }
    pub fn with_a(a: A) -> Self { Self }
    pub fn with_b(b: B) -> Self { Self }
    pub fn with_a_b(a: A, b: B) -> Self { Self }
}

超出这一范围,类型应提供 builder:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# struct A;
# struct B;
# struct C;
# struct Foo;
# struct FooBuilder;
impl Foo {
    pub fn new() -> Self { ... }
    pub fn builder() -> FooBuilder { ... }
}

impl FooBuilder {
    pub fn a(mut self, a: A) -> Self { ... }
    pub fn b(mut self, b: B) -> Self { ... }
    pub fn c(mut self, c: C) -> Self { ... }
    pub fn build(self) -> Foo { ... }
}

构建 Foo 的 builder 的正确名称是 FooBuilder。其方法必须可链式调用,最终方法名为 .build()。可构建的结构体必须有快捷方式 Foo::builder(),而 builder 本身应没有公共的 FooBuilder::new()。设置值 x 的 builder 方法名为 x(),而不是 set_x() 或类似名称。

Builder 与必填参数

必填参数应在创建 builder 时传入,而不是作为 setter 方法。对于有多个必填 参数的 builder,把它们封装进一个参数结构体,并使用 deps: impl Into<Deps> 模式以提供灵活性:

注意: 若 builder 没有必填参数,或只有一个简单参数,则不需要专用的 deps 结构体。不过, 出于向后兼容与 API 演进,即使在简单情形下也更宜使用专用的 deps 结构体,因为这样 将来更容易添加新的必填参数而不破坏现有代码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#[derive(Debug, Clone)]
pub struct FooDeps {
    pub logger: Logger,
    pub config: Config,
}

impl From<(Logger, Config)> for FooDeps { ... }
impl From<Logger> for FooDeps { ... } // 以防我们可以使用默认 Config 实例

impl Foo {
    pub fn builder(deps: impl Into<FooDeps>) -> FooBuilder { ... }
}

此模式允许便捷用法:

  • Foo::builder(logger) - 只需 logger 时
  • Foo::builder((logger, config)) - 两个参数都需要时
  • Foo::builder(FooDeps { logger, config }) - 显式构造结构体

或者,你可以使用 fundle 来简化 FooDeps 的创建:

1
2
3
4
5
6
#[derive(Debug, Clone)]
#[fundle::deps]
pub struct FooDeps {
    pub logger: Logger,
    pub config: Config,
}

此模式支持「依赖注入」,更多细节见这些文档。

运行时相关的 Builder

对于运行时相关、或需要运行时相关配置的类型,提供接受相应运行时参数的专用 builder 创建方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#[cfg(feature="smol")]
#[derive(Debug, Clone)]
pub struct SmolDeps {
    pub clock: Clock,
    pub io_context: Context,
}

#[cfg(feature="tokio")]
#[derive(Debug, Clone)]
pub struct TokioDeps {
    pub clock: Clock,
}

impl Foo {
    #[cfg(feature="smol")]
    pub fn builder_smol(deps: impl Into<SmolDeps>) -> FooBuilder { ... }

    #[cfg(feature="tokio")]
    pub fn builder_tokio(deps: impl Into<TokioDeps>) -> FooBuilder { ... }
}

此做法确保编译期类型安全,并使运行时依赖在 API 面上显式可见。得到的 builder 方法遵循 builder_{runtime}(deps) 模式,其中 {runtime} 表示特定运行时或执行环境。

延伸阅读

复杂类型初始化层次级联 (M-INIT-CASCADED)

本条守护:避免参数混淆的构造。

需要 4 个及以上参数的类型,应通过辅助类型级联其初始化。

1
2
3
4
5
# struct Deposit;
impl Deposit {
    // 参数容易混淆,签名总体上也很笨拙。
    pub fn new(bank_name: &str, customer_name: &str, currency_name: &str, currency_amount: u64) -> Self { }
}

不要提供一长串参数列表,而应按语义对参数分组。应用本指南时, 也请检查 C-NEWTYPE 是否适用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# struct Deposit;
# struct Account;
# struct Currency
impl Deposit {
    // 更好,签名更干净
    pub fn new(account: Account, amount: Currency) -> Self { }
}

impl Account {
    pub fn new_ok(bank: &str, customer: &str) -> Self { }
    pub fn new_even_better(bank: Bank, customer: Customer) -> Self { }
}

服务类型实现 Clone (M-SERVICES-CLONE)

本条守护:公共服务的可组合共享。

重量级服务类型和「线程单例」应实现共享所有权的 Clone 语义,包括你预期会从 Application::init 中使用的任何类型。

在每个线程上,用户本质上应能创建一个资源处理器实例,并让同一线程上的其他处理器复用它:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
impl ThreadLocal for MyThreadState {
    fn init(...) -> Self {

        // 创建可能被多方使用的公共服务实例。
        let common = ServiceCommon::new();

        // 用户可以在此多次自由传入 `common`
        let service_1 = ServiceA::new(&common);
        let service_2 = ServiceA::new(&common);

        Self { ... }
    }
}

然后服务只需克隆其依赖并存储一个新句柄,就好像 ServiceCommon 是共享所有权的智能指针:

1
2
3
4
5
6
7
impl ServiceA {
    pub fn new(common: &ServiceCommon) -> Self {
        // 若我们只需要在 `new` 中访问 `common`,就不必
        // 存储它。否则,克隆一份并存入 `Self`。
        let common = common.clone();
    }
}

在底层,此 Clone 不应创建整个服务的重量级完整拷贝。相反,它应遵循 Arc<Inner> 模式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 包含核心逻辑与数据的实际服务。
struct ServiceCommonInner {}

#[derive(Clone)]
pub ServiceCommon {
    inner: Arc<ServiceCommonInner>
}

impl ServiceCommon {
    pub fn new() {
        Self { inner: Arc::new(ServiceCommonInner::new()) }
    }

    // 方法转发……
    pub fn foo(&self) { self.inner.foo() }
    pub fn bar(&self) { self.inner.bar() }
}

核心功能应是固有方法 (M-ESSENTIAL-FN-INHERENT)

本条守护:易于发现的核心功能。

类型应以固有方式实现核心功能。trait 实现应转发到固有函数,而不是取代它们。不要这样写

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# trait Download {
#     fn download_file(&self, url: impl AsRef<str>);
# }
struct HttpClient {}

// 把核心功能卸到 trait 里,意味着用户
// 必须弄清还要 `use` 哪些其他 trait
// 才能真正使用此类型。
impl Download for HttpClient {
    fn download_file(&self, url: impl AsRef<str>) {
        // ……下载文件的逻辑
    }
}

而要这样写:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# trait Download {
#     fn download_file(&self, url: impl AsRef<str>);
# }
struct HttpClient {}

impl HttpClient {
    fn download_file(&self, url: impl AsRef<str>) {
        // ……下载文件的逻辑
    }
}

// 将调用转发到固有实现。可以使用 `HttpClient`
impl Download for HttpClient {
    fn download_file(&self, url: impl AsRef<str>) {
        Self::download_file(self, url)
    }
}

模块在大小与职责上保持均衡 (M-BALANCED-MODULES)

本条守护:可发现的功能与清晰的 API 用法。

你的模块设计应大致遵循已确立的菜单设计 UX 实践:将合理数量的最重要项放在 crate 根中,并将其余功能按可理解的方式分组到下级模块。

最常见的两种违反是:扁平的模块根包含几十个项却没有清晰排序,或过度使用子模块而 crate 根中没有任何项。虽然有些 crate 这样做说得通(例如自动生成、定义数百个 C 项的 -sys crate,或像 std 和 tokio 这样的伞形 crate),但大多数库 crate 并不属于这种情况。

设计模块布局时,请考虑这些因素:

  • 用户为使用 crate 必须找到的关键项应放进其根中。例如,foo_client crate 大概应把其主要的 Client 结构体放在根里。
  • 其他项应按用例语义分组。名为 traits 和 errors 的模块对谁都没有帮助,而 account、network 和 status 则有。
  • 还要考虑到,模块是放置模块级文档、进一步解释相应子系统的绝佳位置。

不要定义 prelude (M-NO-PRELUDE)

本条守护:干净的命名空间与可靠的下游构建。

crate 不得定义 prelude,也不得定义任何意图以 use foo::* 导入的命名空间。

尽管 Rust 标准库成功地用 prelude 来定义 edition 项,crate 中的 prelude 却弊大于利。以当今的 IDE 支持,它们并无必要;一旦从不同 crate 使用多个 prelude,就可能产生冲突:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use foo::prelude::*;
use bar::prelude::*;
use baz::prelude::*;

_ = Client::new();

// error[E0659]: `Client` is ambiguous
//   --> src/lib.rs:17:13
//    |
// 17 |     _ = Client; 
//    |         ^^^^^^ ambiguous name
//    |
//    = note: ambiguous because of multiple glob imports of a name in the same module

尤其是,prelude 并不能解决糟糕的模块设计。若看起来 prelude 会让 crate 更易使用或理解,这几乎总是表明现有模块系统需要重组,参见 M-BALANCED-MODULES。

参数顺序保持一致 (M-PARAMETER-CONSISTENCY)

本条守护:低开发摩擦。

当相同概念上的参数出现在多个函数中时(在同一 crate 内,或同一生态中的多个 crate 之间),它们应在各处以相同顺序出现:

  • 重要或针对本次调用的参数一般应放在前面,
  • 普遍存在的参数宁可放在后面(例如 &logger),
  • 闭包始终放在最后(函数不应接受超过一个闭包)。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 不好,`user_id` 与 `tenant_id` 的顺序在函数之间对调,
// 且 logger 有时出现在最前,有时出现在最后。
fn create_user(logger: &Logger, user_id: UserId, tenant_id: TenantId) -> Result<()> { ... }
fn delete_user(tenant_id: TenantId, user_id: UserId, logger: &Logger) -> Result<()> { ... }
fn rename_user(user_id: UserId, new_name: &str, tenant_id: TenantId, logger: &Logger) -> Result<()> { ... }

// 好,调用相关参数按一致顺序放在前面,随处可见的
// `logger` 始终放在最后。
fn create_user(tenant_id: TenantId, user_id: UserId, logger: &Logger) -> Result<()> { ... }
fn delete_user(tenant_id: TenantId, user_id: UserId, logger: &Logger) -> Result<()> { ... }
fn rename_user(tenant_id: TenantId, user_id: UserId, new_name: &str, logger: &Logger) -> Result<()> { ... }

集合实现相应的 iter trait (M-COLLECTION-TRAITS)

本条守护:可组合的集合。

自定义集合应实现标准库提供的面向迭代器的 trait。

每当你定义供第三方使用的新集合类型 Collection<T> 时,下列 trait 与类型也应实现,更多细节见此处:

  • 结构体 IntoIter<T>、Iter<T> 和 IterMut<T>,
  • 为它们全部提供 impl Iterator,
  • 方法 c.iter() 和 c.iter_mut(),
  • 为 Collection<T>、&Collection<T> 和 &mut Collection<T> 提供 impl IntoIterator,
  • 为 Collection<T> 提供 impl FromIterator,
  • 为 Collection<T> 提供 Extend,
  • DoubleEndedIterator、ExactSizeIterator 等(若适用)

此外,确保在所有迭代器上实现 size_hint(),并且如实实现。

函数使用 async 而非返回 Future (M-ASYNC-FN)

本条守护:更简单的代码与更易理解的 API。

当两者都可行时,函数应声明为 async fn foo(),而不是 fn foo() -> impl Future。

标记为 async 的函数更地道、更易读。显式返回 Future 的签名只应在必要时使用,例如在 trait 内部,或用于又热又重的异步函数,参见 M-ASYNC-STACK-SIZE。

1
2
3
4
5
6
7
impl Foo {
    // 不好,签名更嘈杂,函数体还需要额外的 `async` 块
    fn foo() -> impl Future<Output = Result<T, E>> { async { Ok(t) } }

    // 好,方法与实现读起来很自然
    async fn foo() -> Result<T, E> { Ok(t) }
}
最后修改 August 21, 2026: 更新 (76fc81a2e)