Rust Trait:让类型接入语言与生态

From Display to Future

3,404 words 30 min read
目录 18 节
  1. Trait 如何进入代码:trait、impl 与 Trait Bound
  2. 类型如何被打印:Debug 与 Display
  3. 复制、默认值与释放:Clone、Copy、Default、Drop
  4. 相等、排序与 Hash:PartialEq、Eq、PartialOrd、Ord、Hash
  5. 类型转换与 ?:From、Into、TryFrom、TryInto、FromStr
  6. 借用与参数适配:AsRef、Borrow、Deref、ToOwned
  7. for 循环背后:Iterator 与 IntoIterator
  8. 集合如何参与 collect():FromIterator 与 Extend
  9. 闭包的三种能力:FnOnce、FnMut、Fn
  10. 错误如何形成调用链:Error、Display、Debug、From
  11. 通用 I/O:Read、BufRead、Write、Seek
  12. 运算符与索引:Add、Sub、Index、IndexMut
  13. 跨越线程边界:Send 与 Sync
  14. 异步计算的协议:Future、IntoFuture、Unpin
  15. 动态大小类型:Sized 与 ?Sized
  16. 静态与动态分发:impl Trait 与 dyn Trait
  17. 后端生态的 Trait:Serialize、Stream、Sink、Service
  18. 什么时候实现哪个 Trait

Rust 里很多看似由语言直接提供的能力,背后其实是 Trait:

println!("{user_id}");

let id = "42".parse::<UserId>()?;

for order in orders {
    // ...
}

tokio::spawn(process(order));

这几行代码分别要求类型实现文本格式化、字符串解析、迭代和跨线程传递相关的 Trait。

Trait 因而不只是“Rust 版本的 interface”。它还是类型接入语言语法和通用算法的方式。一个自定义类型实现了 Display,就能进入格式化系统;实现了 IntoIterator,就能被 for 消费;满足 Send,所有权才可以跨过线程边界。

标准库里的 Trait 很多,没有必要逐个记忆。更实用的学习方式是从问题出发:

这个类型应该怎样打印?
它能否成为 HashMap 的 Key?
一次转换会不会失败?
函数需要取得所有权,还是只需要一种引用?
这个容器应该怎样参与 for 和 collect?
它能否在线程和异步任务之间移动?

本文从一个后端服务里常见的 UserId、订单集合和错误类型开始,逐步为它们补上所需能力。重点不是把所有 Trait 都实现一遍,而是知道哪个 Trait 对应哪个问题,以及哪些实现最好不要轻易写。

Trait 如何进入代码:traitimpl 与 Trait Bound

先定义一个最简单的业务类型:

struct UserId(u64);

如果一个函数只接受 UserId,它知道参数的完整类型:

fn load_user(id: UserId) {
    // ...
}

如果函数只关心“这个值能够被打印”,就可以把要求写成 Trait Bound:

use std::fmt::Display;

fn log_value<T>(value: T)
where
    T: Display,
{
    println!("{value}");
}

T: Display 并不是在描述 T 的继承关系。它是在告诉编译器:函数体只会依赖 Display 所承诺的能力。

Trait 的价值也正在这里。泛型代码不需要知道所有可能的类型,只需要声明自己会使用哪些操作:

想使用 ==           PartialEq
想放进 HashMap      Eq + Hash
想调用 clone()      Clone
想放进新线程        Send + 'static
想用 for 遍历       IntoIterator

至于 Trait 如何定义、孤儿规则、关联类型和静态分发,这些属于 Trait 本身的机制。下面更关注标准库已经定义好的通用协议,以及业务类型怎样接入它们。

类型如何被打印:DebugDisplay

DebugDisplay 都能把值写进 Formatter,但面向的读者不同。

Debug
    面向开发者
    适合日志、断言和临时排查

Display
    面向类型的使用者
    应当提供稳定、自然的文本表示

Debug 通常直接派生:

#[derive(Debug)]
struct UserId(u64);

let id = UserId(42);
println!("{id:?}");

输出可能是:

UserId(42)

这种格式便于确认类型结构,但不适合作为接口响应或持久化格式。派生出来的 Debug 表示可能随着字段调整而改变。

Display 通常需要手写,因为编译器不知道什么才是这个类型自然的外部表示:

use std::fmt;

impl fmt::Display for UserId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

现在可以写:

println!("user_id={user_id}");
let text = user_id.to_string();

我们没有为 UserId 实现 ToString。标准库已经为所有实现 Display 的类型提供了 ToString 的毯式实现。

这也是使用标准 Trait 的一个好处:实现最核心的协议,就能自动获得周边能力。

不应当把 Display 当成通用序列化机制。如果 UserId 需要稳定地编码进 JSON、数据库或网络协议,应使用对应的序列化规则,而不是依赖某段展示文本永远不变。

复制、默认值与释放:CloneCopyDefaultDrop

这四个 Trait 都与值的生命周期有关,但表达的意思不同。

Clone:显式复制

#[derive(Clone)]
struct UserProfile {
    name: String,
    email: String,
}

let backup = profile.clone();

clone() 可能很便宜,也可能复制大量堆数据。Trait 只承诺能够得到一个独立的同类值,不承诺复杂度。

在泛型 API 中写下 T: Clone,等于允许实现代码复制调用者传入的值。这个要求不应该为了绕过所有权问题随手添加。很多时候,借用、移动所有权或使用 Arc 更能表达真实需求。

Copy:隐式的轻量复制

UserId 只包含一个 u64,复制成本明确,可以派生 Copy

#[derive(Debug, Clone, Copy)]
struct UserId(u64);

let first = UserId(42);
let second = first;

println!("{first:?} {second:?}");

赋值以后 first 仍能使用,因为这里发生的是 Copy,而不是所有权移动。

实现 Copy 的类型必须也实现 Clone。它不能包含 StringVec 等非 Copy 字段,也不能实现 Drop

Copy 不只是性能标记,它会改变赋值和传参的语义。文件句柄、锁 Guard 或数据库事务即使内部只有一个整数,也不应该因为“复制很便宜”就实现 Copy,因为复制资源所有权本身没有合理含义。

Default:存在一个普通起点

配置和构建器经常适合 Default

#[derive(Debug)]
struct RetryPolicy {
    max_attempts: u8,
    timeout_ms: u64,
}

impl Default for RetryPolicy {
    fn default() -> Self {
        Self {
            max_attempts: 3,
            timeout_ms: 500,
        }
    }
}

调用方可以只覆盖关心的字段:

let policy = RetryPolicy {
    timeout_ms: 800,
    ..Default::default()
};

但并非所有类型都有合理默认值。如果 UserId(0) 在业务上非法,为了方便构造而派生 Default 会制造一个无效状态。此时应该提供返回 Result 的构造函数,而不是编造默认值。

Drop:作用域结束时收尾

Drop 是 RAII 的出口。下面的 Guard 在离开作用域时记录耗时:

use std::time::Instant;

struct TimerGuard {
    name: &'static str,
    started_at: Instant,
}

impl Drop for TimerGuard {
    fn drop(&mut self) {
        println!(
            "{} took {:?}",
            self.name,
            self.started_at.elapsed(),
        );
    }
}

无论函数正常返回还是通过 ? 提前退出,已经创建的 Guard 都会被 Drop。

Drop::drop 不能返回错误,也不能 .await。需要可靠落库、发送网络请求或异步关闭资源时,不能只依赖 Drop;应该提供显式的 close()commit()shutdown().await,Drop 只负责最后的兜底清理。

相等、排序与 Hash:PartialEqEqPartialOrdOrdHash

业务 ID 经常需要比较、排序并作为集合的 Key:

#[derive(
    Debug,
    Clone,
    Copy,
    PartialEq,
    Eq,
    PartialOrd,
    Ord,
    Hash,
)]
struct UserId(u64);

于是它可以用于:

use std::collections::{BTreeSet, HashMap};

let mut users = HashMap::new();
users.insert(UserId(42), "lanran");

let mut sorted = BTreeSet::new();
sorted.insert(UserId(42));

这些 Trait 是成组出现的,但并不完全相同:

PartialEq    可以使用 == 和 !=
Eq           相等关系满足自反性等完整约束

PartialOrd   某些值之间可能无法排序
Ord          任意两个值都存在全序关系

Hash         可以生成用于哈希集合的摘要

浮点数只实现 PartialEqPartialOrd,没有实现 EqOrd

assert!(f64::NAN != f64::NAN);

NaN 不满足 x == x,因此不能声称具有完整等价关系。

自定义实现 EqHash 时必须保持一致:

如果 a == b,那么 ab 必须产生相同的 Hash。

否则 HashMap 可能插入一个 Key 后,再也无法使用“相等”的值找到它。

派生实现会按照字段顺序比较和 Hash。对于实体类型,要先决定相等表示“ID 相同”,还是表示所有字段完全相同。不能因为派生方便,就让数据库实体的相等语义随着新增字段悄悄改变。

类型转换与 ?FromIntoTryFromTryIntoFromStr

UserId 从数据库读取时可能是 u64,从 URL 参数进入时却是字符串。转换是否可能失败,决定了应该使用哪一组 Trait。

不会失败的转换:FromInto

从合法 UserId 取出数字不会失败:

impl From<UserId> for u64 {
    fn from(value: UserId) -> Self {
        value.0
    }
}

调用方既可以写:

let raw = u64::from(user_id);

也可以写:

let raw: u64 = user_id.into();

实现 From<UserId> for u64 后,标准库会自动提供反向视角的 Into<u64> for UserId。定义转换时通常优先实现 From,泛型参数如果想接纳更广泛的调用方,则可以使用 Into Bound。

From 适合无损、明确、不会失败的转换。如果转换需要截断、默认值或业务校验,它就不应该伪装成 From

可能失败的转换:TryFromTryInto

把任意 u64 转成 UserId 需要排除零:

#[derive(Debug)]
struct InvalidUserId;

impl TryFrom<u64> for UserId {
    type Error = InvalidUserId;

    fn try_from(value: u64) -> Result<Self, Self::Error> {
        if value == 0 {
            Err(InvalidUserId)
        } else {
            Ok(UserId(value))
        }
    }
}

使用时失败路径会留在类型里:

let id = UserId::try_from(raw_id)?;

实现 TryFrom<A> for B 后会自动获得 TryInto<B> for A。在泛型函数的输入 Bound 中,TryInto 往往能接纳更多实现:

fn load_user<I>(input: I) -> Result<User, Error>
where
    I: TryInto<UserId>,
    Error: From<I::Error>,
{
    let id = input.try_into()?;
    // ...
}

签名如果因此变得比业务逻辑更复杂,也没有必要强行泛化。只接受 UserId,让边界层先完成校验,通常更清楚。

字符串解析:FromStr

URL、环境变量和命令行参数经常来自字符串:

use std::str::FromStr;

impl FromStr for UserId {
    type Err = ParseUserIdError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        let raw = value
            .parse::<u64>()
            .map_err(ParseUserIdError::InvalidNumber)?;

        UserId::try_from(raw)
            .map_err(|_| ParseUserIdError::Zero)
    }
}

于是可以直接使用标准解析入口:

let id: UserId = "42".parse()?;

? 在这里传播错误。不同错误类型之间需要转换时,通常通过 From 完成。例如为应用错误实现 From<ParseUserIdError> 后,解析错误就能沿着返回 Result<_, AppError> 的函数继续向上返回。

借用与参数适配:AsRefBorrowDerefToOwned

这几个 Trait 都能“得到另一种引用”,却服务于不同目的。

AsRef:便宜的引用转换

读取配置文件时,调用方可能持有 &strStringPathBuf

use std::fs;
use std::io;
use std::path::Path;

fn read_config(path: impl AsRef<Path>) -> io::Result<String> {
    fs::read_to_string(path.as_ref())
}

调用都可以成立:

read_config("config.toml")?;
read_config(String::from("config.toml"))?;
read_config(Path::new("config.toml"))?;

AsRef 表示低成本的 reference-to-reference 转换。它不适合隐藏需要分配、解析或可能失败的操作。

输入参数也不必逢类型就写 impl AsRef<_>。如果函数只服务于一个明确的业务类型,直接接收 &UserId&Path 通常更容易阅读。

Borrow:借用后仍保持集合语义

HashMap<String, User> 可以用 &str 查询:

let mut users = std::collections::HashMap::new();
users.insert(String::from("lanran"), user);

let found = users.get("lanran");

这是因为 String: Borrow<str>,而且 String 与它借出的 str 具有一致的相等、排序和 Hash 语义。

BorrowAsRef 的承诺更强。它主要服务于泛型集合和算法:拥有所有权的 Key 可以使用对应的借用形式查询,又不会改变 Key 的语义。

Deref:让智能指针表现得像目标值

String 可以调用 str 的方法,Box<T>Arc<T> 可以像 T 一样被借用,背后都与 Deref 有关。

use std::ops::Deref;

fn first_char(text: &String) -> Option<char> {
    text.deref().chars().next()
}

实际代码直接写 text.chars() 即可,编译器会进行 Deref Coercion。

不要为了让 UserId 自动获得 u64 的所有方法而实现 Deref<Target = u64>Deref 适合智能指针和具有透明目标关系的容器,不是模拟继承或省略 .0 的通用工具。业务转换使用显式方法或 From 更清楚。

ToOwnedCow

ToOwned 可以从借用数据得到拥有所有权的版本:

str     → String
[T]     → Vec<T>

它经常通过 Cow 出现:

use std::borrow::Cow;

fn normalize_name(name: &str) -> Cow<'_, str> {
    if name.chars().all(|ch| !ch.is_uppercase()) {
        Cow::Borrowed(name)
    } else {
        Cow::Owned(name.to_lowercase())
    }
}

无需修改时直接借用,需要修改时才分配。这比一进入函数就无条件 to_string() 更能表达真实成本。

for 循环背后:IteratorIntoIterator

Iterator 表示一个正在产生元素的过程:

pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}

只要实现 next(),就会获得 mapfilterfoldziptake 等大量默认方法。

但集合本身不一定就是 Iterator。一个订单批次可以被遍历,同时保留自己的领域方法:

struct OrderBatch {
    orders: Vec<Order>,
}

impl OrderBatch {
    fn total_amount(&self) -> Money {
        self.orders
            .iter()
            .fold(Money(0), |total, order| {
                total + order.amount
            })
    }
}

让它进入 for 循环,需要实现 IntoIterator

impl IntoIterator for OrderBatch {
    type Item = Order;
    type IntoIter = std::vec::IntoIter<Order>;

    fn into_iter(self) -> Self::IntoIter {
        self.orders.into_iter()
    }
}

现在:

for order in batch {
    process(order);
}

会消费整个 OrderBatch,每次得到一个拥有所有权的 Order

如果希望借用遍历,还可以为引用实现:

impl<'a> IntoIterator for &'a OrderBatch {
    type Item = &'a Order;
    type IntoIter = std::slice::Iter<'a, Order>;

    fn into_iter(self) -> Self::IntoIter {
        self.orders.iter()
    }
}

于是两种循环拥有不同语义:

for order in &batch {
    // order: &Order,batch 仍可继续使用
}

for order in batch {
    // order: Order,batch 被消费
}

这比简单记住 .iter().iter_mut().into_iter() 更重要:它们分别产生共享借用、可变借用和拥有所有权的元素。

集合如何参与 collect()FromIteratorExtend

collect() 是 Iterator 的方法,但它并不知道怎样构造所有集合。目标类型通过 FromIterator 告诉它如何接收一串元素。

impl FromIterator<Order> for OrderBatch {
    fn from_iter<I>(iter: I) -> Self
    where
        I: IntoIterator<Item = Order>,
    {
        Self {
            orders: iter.into_iter().collect(),
        }
    }
}

现在可以写:

let batch: OrderBatch = orders
    .into_iter()
    .filter(|order| order.is_pending())
    .collect();

collect() 依靠返回类型推断目标集合。推断不出来时,可以使用 Turbofish:

let batch = orders.into_iter().collect::<OrderBatch>();

已有集合继续吸收元素则使用 Extend

impl Extend<Order> for OrderBatch {
    fn extend<I>(&mut self, iter: I)
    where
        I: IntoIterator<Item = Order>,
    {
        self.orders.extend(iter);
    }
}
FromIterator
    从零构造一个集合

Extend
    向已有集合追加元素

这里有一个领域建模上的限制:FromIterator::from_iter 直接返回 Self,不能返回 Result<Self, E>。如果 OrderBatch 的构造需要校验“所有订单必须属于同一用户”,就不应该用一个会悄悄接受非法数据的 FromIterator 实现。

可以先收集为 Vec,再调用显式的校验构造函数:

let orders = iter.collect::<Vec<_>>();
let batch = OrderBatch::try_new(orders)?;

标准 Trait 很方便,但不能为了获得语法糖而削弱类型不变量。

闭包的三种能力:FnOnceFnMutFn

闭包实现哪个 Trait,取决于它怎样使用捕获的数据。

FnOnce:可能消费捕获值

fn run_once<F>(task: F)
where
    F: FnOnce(),
{
    task();
}

let payload = String::from("event");

run_once(move || {
    send(payload);
});

闭包把 payload 移给 send,只能调用一次。线程任务和很多异步任务天然是一次性操作,所以经常接受 FnOnce

FnMut:多次调用并修改状态

fn retry<F, T, E>(mut operation: F) -> Result<T, E>
where
    F: FnMut() -> Result<T, E>,
{
    let mut attempts = 0;

    loop {
        attempts += 1;

        match operation() {
            Ok(value) => return Ok(value),
            Err(error) if attempts == 3 => return Err(error),
            Err(_) => continue,
        }
    }
}

FnMut 允许闭包修改捕获变量:

let mut attempts = 0;

retry(|| {
    attempts += 1;
    call_remote()
});

Fn:多次调用且不需要独占修改

过滤器、纯计算回调经常只读取环境:

fn select_orders<F>(orders: &[Order], predicate: F) -> Vec<&Order>
where
    F: Fn(&Order) -> bool,
{
    orders
        .iter()
        .filter(|order| predicate(order))
        .collect()
}

三者形成能力层级:

Fn
    也满足 FnMut 和 FnOnce

FnMut
    也满足 FnOnce

FnOnce
    只承诺至少能调用一次

设计参数时,接受满足需求的最宽松 Trait。只调用一次的 API 使用 FnOnce,就不会无故拒绝那些需要消费捕获值的闭包。

错误如何形成调用链:ErrorDisplayDebugFrom

一个标准错误类型通常需要同时具备:

Debug      开发者调试
Display    人类可读描述
Error      接入错误链
From       在 ? 处转换错误

前面的 ParseUserIdError 可以写成:

use std::error::Error;
use std::fmt;
use std::num::ParseIntError;

#[derive(Debug)]
enum ParseUserIdError {
    InvalidNumber(ParseIntError),
    Zero,
}

impl fmt::Display for ParseUserIdError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::InvalidNumber(_) => write!(f, "user id is not a number"),
            Self::Zero => write!(f, "user id must not be zero"),
        }
    }
}

impl Error for ParseUserIdError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            Self::InvalidNumber(error) => Some(error),
            Self::Zero => None,
        }
    }
}

应用层错误可以接住它:

#[derive(Debug)]
enum AppError {
    InvalidUserId(ParseUserIdError),
    Database(DatabaseError),
}

impl From<ParseUserIdError> for AppError {
    fn from(error: ParseUserIdError) -> Self {
        Self::InvalidUserId(error)
    }
}

于是:

fn parse_request(value: &str) -> Result<UserId, AppError> {
    let id = value.parse::<UserId>()?;
    Ok(id)
}

? 发现当前错误是 ParseUserIdError,函数返回的是 AppError,便通过 From 完成转换。

业务项目通常会使用 thiserror 派生这些样板实现,应用最外层也可能使用 anyhow 聚合上下文。但理解 DisplayError::sourceFrom 的关系,才能知道这些库替我们生成了什么。

公共库不应只返回 String。字符串丢失了可匹配的错误类型、source 链和结构化信息,调用方只能解析文本判断错误类别。

通用 I/O:ReadBufReadWriteSeek

假设服务需要导入一份逐行 JSON 订单文件。函数如果直接接收 File,测试就必须创建临时文件:

fn import_orders(file: std::fs::File) {
    // ...
}

真正需要的能力其实只是按行读取:

use std::io::{self, BufRead};

fn import_orders<R>(reader: R) -> Result<Vec<Order>, ImportError>
where
    R: BufRead,
{
    reader
        .lines()
        .map(|line| {
            let line = line?;
            serde_json::from_str(&line).map_err(ImportError::from)
        })
        .collect()
}

生产环境传文件缓冲:

let file = std::fs::File::open(path)?;
let reader = std::io::BufReader::new(file);
let orders = import_orders(reader)?;

测试可以直接使用内存:

let input = std::io::Cursor::new(
    b"{\"id\":1}\n{\"id\":2}\n"
);

let orders = import_orders(input)?;

同一段解析逻辑不需要知道字节来自文件、内存还是其他 Reader。

几个 Trait 的侧重点是:

Read
    从某处读取字节

BufRead
    在 Read 之上提供内部缓冲和按行读取

Write
    向某处写入字节并支持 flush

Seek
    调整当前读写位置

这些是同步 I/O Trait。Tokio 中的 AsyncReadAsyncWrite 处理的是异步读写协议,不能把 std::io::Read 直接当成异步接口使用。

运算符与索引:AddSubIndexIndexMut

Rust 的运算符也通过 Trait 开放给自定义类型。

金额相加具有清楚的领域含义:

use std::ops::{Add, Sub};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Money(i64);

impl Add for Money {
    type Output = Money;

    fn add(self, rhs: Money) -> Self::Output {
        Money(self.0 + rhs.0)
    }
}

impl Sub for Money {
    type Output = Money;

    fn sub(self, rhs: Money) -> Self::Output {
        Money(self.0 - rhs.0)
    }
}
let payable = price + shipping - discount;

Add<Rhs = Self> 的右操作数是泛型参数,Output 是关联类型,因此还可以表达 Duration + Timestamp -> Timestamp 之类左右类型不同的运算。

IndexIndexMut 支持 container[index],但索引不存在时通常会 panic。需要返回 Option 的查找仍应使用 get(),不要为了获得方括号语法隐藏正常的失败分支。

运算符实现应当符合读者直觉。Money + Money 很自然,User + Order 即使能够编译,也很难形成稳定语义。

跨越线程边界:SendSync

SendSync 没有方法,却决定类型能否进入并发代码:

T: Send
    T 的所有权可以安全地转移到另一条线程

T: Sync
    &T 可以安全地在线程之间共享

更准确地说,当且仅当 &T: Send 时,T: Sync

它们是 Auto Trait。普通结构体只要所有字段满足条件,编译器通常会自动推导:

struct Job {
    user_id: UserId,
    payload: String,
}

// UserId 和 String 都是 Send,Job 自动成为 Send

Rc<T> 使用非原子引用计数,因此不能安全地在线程间移动或共享。Arc<T> 使用原子引用计数,但它也不会让内部的 T 自动线程安全:

Arc<T>: Send + Sync
    仍要求 T 满足相应的 Send + Sync 条件

需要跨线程修改共享数据时,经常使用:

Arc<Mutex<State>>

Arc 解决共同所有权,Mutex 解决同一时刻谁能修改。它们不是互相替代的两个方案。

thread::spawntokio::spawn 报出 Future is not Send 时,应当检查哪些值跨越了线程或 .await,而不是尝试手写:

unsafe impl Send for MyType {}

手动实现 SendSync 属于底层 unsafe 设计,承诺错误会造成数据竞争和未定义行为。绝大多数业务类型应该让编译器自动判断。

异步计算的协议:FutureIntoFutureUnpin

async fn 返回的匿名类型实现了 Future

pub trait Future {
    type Output;

    fn poll(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>,
    ) -> Poll<Self::Output>;
}

.await 反复推进这个协议:

Poll::Pending
    当前不能继续,登记 Waker 并让出执行权

Poll::Ready(value)
    异步计算完成

普通业务代码很少手动实现 Future。编译器把 async fn 转换成状态机,Tokio 等 Runtime 负责 Pin 和 poll。

IntoFutureIntoIterator 的角色相似:

IntoIterator
    一个值怎样进入 for 循环

IntoFuture
    一个值怎样进入 .await

直接 .await 一个 Future 时,有对应的通用实现。库也可以让 Builder 实现 IntoFuture,调用 .await 时再生成真正的异步过程。不过这种设计会让“构建配置”和“开始执行”共享一种语法,使用时要确保行为足够直观。

Unpin 表示类型不依赖 Pin 提供的位置稳定保证。多数普通类型自动实现 Unpin;编译器生成的 Future 则不应该被调用方擅自假定为 Unpin

日常代码中:

直接 .await              不需要显式 Pin
tokio::spawn             Runtime 处理 Pin
join! / select! 临时使用 宏通常处理 Pin

重复借用同一个 !Unpin Future
    才常见 tokio::pin! 或 Box::pin

这几个 Trait 的重要性不在于需要频繁手写,而在于它们定义了编译器状态机和异步 Runtime 之间的接口。

动态大小类型:Sized?Sized

泛型参数默认带有 Sized Bound:

fn store<T>(value: T) {
    // 实际隐含 T: Sized
}

编译器需要知道按值传递的 T 占多少空间,才能安排栈帧和移动操作。

但有些类型没有编译期固定大小:

str
[T]
dyn Trait

它们通常放在某种指针后面:

&str
&[T]
Box<dyn Error>
Arc<dyn Handler + Send + Sync>

泛型实现只通过引用处理这类类型时,可以放宽默认约束:

fn print_value<T>(value: &T)
where
    T: std::fmt::Display + ?Sized,
{
    println!("{value}");
}

?Sized 不是要求 T 必须没有大小,而是表示这里不再强制 T: Sized。函数既能接受普通确定大小类型,也能接受 strdyn Display

标准库中大量智能指针和引用实现都会出现 T: ?Sized,因为 Box<str>Arc<[T]>&dyn Trait 都是合理用法。

静态与动态分发:impl Traitdyn Trait

Trait 可以通过泛型静态分发:

fn render(value: &impl Display) {
    println!("{value}");
}

也可以通过 Trait Object 动态分发:

fn render(value: &dyn Display) {
    println!("{value}");
}

表面只差一个关键字,选择发生在不同阶段:

impl Trait / T: Trait
    编译期确定具体类型
    静态分发,可以单态化和内联

dyn Trait
    运行期通过 vtable 找到实现
    可以隐藏和混合不同具体类型

函数一次只处理某一种已知 Writer,泛型通常更直接:

fn write_report<W: Write>(writer: &mut W) -> io::Result<()> {
    // ...
}

插件系统需要在一个集合中保存不同实现,则适合 Trait Object:

let plugins: Vec<Box<dyn Plugin + Send + Sync>> = vec![
    Box::new(AuditPlugin::new()),
    Box::new(MetricsPlugin::new()),
];

不是所有 Trait 都能构造 dyn Trait。方法如果按值返回 Self、包含某些泛型参数,或者 Trait 本身要求 Self: Sized,就可能不满足 Dyn Compatibility。

不要把动态分发简单理解成“性能差”。虚表调用的成本通常不是后端系统的主要瓶颈。更关键的问题是:调用方是否需要知道具体类型,是否需要异构集合,以及 API 是否值得承担动态分配和生命周期复杂度。

后端生态的 Trait:SerializeStreamSinkService

标准 Trait 提供语言级协议,生态库也会围绕自己的领域建立通用接口。

Serde:SerializeDeserialize

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
#[serde(transparent)]
struct UserId(u64);

#[derive(Debug, Serialize, Deserialize)]
struct CreateOrder {
    user_id: UserId,
    items: Vec<OrderItem>,
}

类型实现 Serialize 后,可以被任何兼容 Serializer 转成 JSON、MessagePack 等格式;实现 Deserialize 后,则可以从兼容 Deserializer 构造。

业务类型
    实现 Serialize / Deserialize

数据格式
    实现 Serializer / Deserializer

二者通过 Serde Data Model 解耦,因此不需要为每一组“业务类型 × 数据格式”单独写转换代码。

业务代码通常使用 derive,只有字段布局、兼容协议或零拷贝借用有特殊要求时才手写实现。

异步序列:StreamSink

Future 最终只产生一次结果,Stream 则可以异步产生一串值:

Future<Output = T>
    Pending ... Ready(T)

Stream<Item = T>
    Pending ... Ready(Some(T)) ... Ready(None)

消息订阅、分页结果、WebSocket 输入都可以表示成 Stream。StreamExt 再提供 mapfilterbuffer_unordered 等组合方法。

Sink<Item> 表示异步接收一串值的目标。Channel Sender、Socket Writer 等都可以抽象成 Sink。它的 poll_ready 还能表达下游当前没有容量,调用方需要等待背压解除。

Tower:Service

Tower 把一次异步服务调用抽象为:

pub trait Service<Request> {
    type Response;
    type Error;
    type Future: Future<
        Output = Result<Self::Response, Self::Error>
    >;

    fn poll_ready(
        &mut self,
        cx: &mut Context<'_>,
    ) -> Poll<Result<(), Self::Error>>;

    fn call(&mut self, request: Request) -> Self::Future;
}

call 表示:

Request → Future<Result<Response, Error>>

poll_ready 则让服务在接收请求前声明自己是否还有容量。限流、超时、重试和日志中间件都可以包装另一个 Service,而不需要知道它最终通过 HTTP、gRPC 还是内存函数处理请求。

Timeout<Service>
Retry<Service>
RateLimit<Service>
Trace<Service>

这很好地体现了 Trait 在大型生态中的作用:定义一个足够小、能够组合的核心协议,其余能力围绕它逐层叠加。

什么时候实现哪个 Trait

遇到自定义类型时,可以先从使用场景反推 Trait:

需求常用 Trait
输出调试信息Debug
提供自然的用户文本Display
显式复制值Clone
允许赋值时隐式复制Copy + Clone
提供普通默认配置Default
离开作用域时清理资源Drop
使用 ==PartialEq,必要时 Eq
作为 HashMap KeyEq + Hash
参与完整排序Ord + Eq
无失败的值转换From
可能失败的值转换TryFrom
从字符串解析FromStr
接受多种引用参数AsRef
使用借用形式查询集合Borrow
表现为智能指针Deref
支持 forIntoIterator
支持 collect()FromIterator
向已有集合追加元素Extend
接受一次性回调FnOnce
接受有状态回调FnMut
接受可共享调用的回调Fn
接入标准错误链Error + Display + Debug
处理同步字节流Read / Write / BufRead
跨线程移动所有权Send
跨线程共享引用Sync
表示一次异步计算Future
动态保存不同实现dyn Trait
接入 SerdeSerialize / Deserialize
接入 Tower 中间件Service

还可以按实现方式分成三类。

经常直接派生:

Debug
Clone
Copy
Default
PartialEq / Eq
PartialOrd / Ord
Hash
Serialize / Deserialize

经常根据领域语义手写:

Display
From / TryFrom / FromStr
Iterator / IntoIterator
Error
Drop

需要理解,但业务代码很少手写:

Send / Sync
Future / Unpin
Deref
Service

这里没有绝对边界。例如框架作者会频繁实现 FutureService,而普通接口服务更多是在函数签名里使用它们。

Trait 最有价值的地方,不是为类型贴上越来越多标签。每个实现都应该回答一个具体问题:这个类型是否真的存在自然文本表示,转换是否真的不会失败,复制是否真的轻量,比较和 Hash 是否遵守同一套语义。

协议越通用,实现承担的承诺就越长期。选对 Trait,类型便能自然进入 Rust 已有的语法和生态;选错 Trait,得到的语法糖则可能把成本和业务约束一起藏起来。

References

  1. The Rust Programming Language: Traits
  2. Rust Standard Library: Formatting Traits
  3. Rust Standard Library: Clone
  4. Rust Standard Library: Copy
  5. Rust Standard Library: Comparison Traits
  6. Rust Standard Library: Conversion Traits
  7. Rust Standard Library: Borrow
  8. Rust Standard Library: Deref
  9. Rust Standard Library: Iterator
  10. Rust Standard Library: IntoIterator
  11. Rust Standard Library: FromIterator
  12. Rust Standard Library: Fn, FnMut, and FnOnce
  13. Rust Standard Library: Error
  14. Rust Standard Library: Read
  15. Rust Standard Library: Send
  16. Rust Standard Library: Sync
  17. Rust Standard Library: Future
  18. Rust Standard Library: Unpin
  19. Serde: Serialize and Deserialize
  20. Futures: Stream
  21. Tower: Service