4.6 dyn Trait 与替代方案

译文 · 基于 Learning Rust

dyn Trait 与替代方案

原文链接: https://quinedot.github.io/rust-learning/dyn-trait-vs.html

在熟悉 Rust 的过程中,起初可能难以判断何时应使用 dyn Trait,以及何时应使用其他类型机制,例如 impl Trait 或泛型。

本节我们将根据使用场景探讨一些权衡。

泛型函数与参数位置 impl Trait

预备知识:什么是参数位置 impl Trait?

当我们谈论参数位置的 impl Trait(即 APIT)时,指的是如下函数:

1
2
3
# use std::fmt::Display;
fn foo(d: impl Display) { println!("{d}"); }
// APIT:  ^^^^^^^^^^^^

也就是说,将 impl Trait 用作函数的参数类型。

截至目前,APIT 在大多数情况下与泛型参数相同:

1
2
# use std::fmt::Display;
fn foo<D: Display>(d: D) { println!("{d}"); }

主要区别在于泛型:

  • 允许函数作者引用 D
    • 例如 D::to_string(&d)
  • 允许函数调用方对函数进行 turbofish
    • 例如 let function_pointer = foo::<String>;
  • 与 impl use<..> 返回类型兼容
    • 而 APIT 不兼容:
    # use std::fmt::Display;
    fn works<D: Display>(_: &D) -> impl Sized + use<D> {}
    fn fails(_: &impl Display) -> impl Sized + use<> {}
    

这些差异是因为 impl Display 参数在函数内部和外部都不可命名。

未来可能还有更多差异,但至少目前,泛型是更灵活、因而更优越的形式——除非你强烈厌恶 <...> 语法。

除上述差异外,将 dyn Trait 与 APIT 比较,本质上与将 dyn Trait 与带有泛型类型参数的函数比较相同。

泛型函数与 dyn Trait 的权衡

这里我们要在如下签名之间做出选择:

1
2
3
4
5
6
7
8
# trait Trait {}
// 拥有或借用的泛型
fn foo1<T: Trait>(t: T) {}
fn bar1<T: Trait + ?Sized>(t: &T) {}

// 拥有或借用的 `dyn Trait`
fn foo2(t: Box<dyn Trait + '_>) {}
fn bar2(t: &dyn Trait) {}

当函数具有泛型参数时,该参数会为每次用于调用该函数的具体类型进行单态化(在生命周期擦除之后)。也就是说,参数所承担的每种类型都会在编译后的代码中产生一个不同的函数。(其中一些结果函数在可能时可能被优化消除或合并。)根据调用方式的不同,foo1 和 bar1 可能有许多副本。

但在(生命周期擦除之后),dyn Trait 是一种单一的具体类型。foo2 和 bar2 只会有一个副本。

然而在典型的 Rust 程序中,泛型参数优先于 dyn Trait 参数。原因如下:

  • 每个单态化函数通常可以更好地优化
  • Trait 约束比 dyn Trait 更通用
    • 无 dyn 兼容性顾虑(T: Clone 可行)
    • 无单一 trait 限制(允许 T: Trait1 + Trait2)
  • 更少通过动态分发的间接层
  • 拥有类型情况下无需装箱
    • Box 在 #![no_std] 程序中甚至不可用

dyn Trait 版本具有以下优势:

总体而言,除非有特定理由在参数位置选择 dyn Trait,应优先使用泛型。

返回位置 impl Trait 与 TAIT

预备知识:什么是返回位置 impl Trait 和 TAIT?

当我们谈论返回位置的 impl Trait(即 RPIT)时,指的是如下函数:

1
2
3
4
// RPIT:                vvvvvvvvvvvvvvvvvvvvvvv
fn foo<T>(v: Vec<T>) -> impl Iterator<Item = T> {
    v.into_iter().inspect(|t| println!("{t:p}"))
}

与 APIT 不同, RPIT 与泛型类型参数并不相同。它们是不透明类型别名或不透明类型别名构造器。在上例中,RPIT 是依赖于函数输入类型参数(T)的不透明类型别名构造器。对于每个具体的 T,RPIT 也是单一具体类型的别名。

函数体和编译器仍知道具体类型是什么,但对调用方和其他代码这是不透明的。相反,你能使用该类型的方式仅限于与 impl Trait 中的 trait 兼容的方式,加上具体类型碰巧实现的任何自动 trait。(或由此可证明的性质,例如 blanket trait 实现。)

「单一」是关键:以下代码无法编译,因为它试图返回两种不同类型。Rust 是严格静态类型语言,这不可能——RPIT 的不透明性不能也不应改变这一点。

1
2
3
4
# use std::fmt::Display;
fn foo(b: bool) -> impl Display {
    if b { 0 } else { "hi!" }
}

type 别名 impl Trait(TAIT)是 RPIT 的推广,尚未稳定, 但可能在不太久的将来稳定。TAIT 允许为不透明类型定义别名,使其可被命名并在多处使用。

1
2
3
4
5
6
7
8
9
#![feature(type_alias_impl_trait)]
# fn main() {}
type MyDisplay = impl std::fmt::Display;

#[define_opaque(MyDisplay)]
fn foo() -> MyDisplay { "hello," }

#[define_opaque(MyDisplay)]
fn bar() -> MyDisplay { " world" }

概念上(也希望实际上),RPIT 会类似地脱糖为 TAIT:

1
2
3
4
5
6
7
8
9
#![feature(type_alias_impl_trait)]
# fn main() {}
# use std::fmt::Display;
fn foo1() -> impl Display { "hi" }

// 一样……或者说如此
type __Unnameable_Tait = impl Display;
#[define_opaque(__Unnameable_Tait)]
fn foo2() -> __Unnameable_Tait { "hi" }

TAIT 仍必须是单一具体类型的别名。

从调用方角度看,不透明类型的一般缺点还包括:

  • 不透明类型对其所有参数都是不变的,而名义 struct 不必如此
  • 任何 trait(包括局部 trait)都不能在不透明类型上实现
  • 名义 struct 的所有 trait 实现、公共字段和公共固有方法都可用,而不透明类型揭示的信息少得多

RPIT 与 dyn Trait 的权衡

RPIT 与 dyn Trait 返回对函数作者共享一些好处:

  • 只要约束不变,可以更改具体或基类型
  • 可以返回不可命名类型,例如闭包
  • 简化复杂类型,例如长迭代器组合器链

dyn Trait 确实有一些限制和缺点:

  • 没有子 trait 样板代码,只能支持一个非自动 trait
    • 相比之下,RPIT 可以返回 impl Trait1 + Trait2
  • 只支持 dyn 兼容的 trait
    • 相比之下,RPIT 可以返回 impl Clone
  • 返回拥有类型必须以某种形式装箱
  • 承担不知道基类型和执行动态分发的典型优化代价

然而 RPIT 也有缺点:

  • 作为不透明别名,只能返回一种实际的具体类型
  • 目前返回类型不可命名,对使用者可能别扭
    • 例如无法将结果作为非泛型字段存入 struct
    • ……除非不透明类型约束是 dyn 兼容的,你可以自行类型擦除
  • 自动 trait 会泄漏,函数作者很容易意外破坏 SemVer
    • 而 dyn Trait 中自动 trait 是显式的
  • trait 中的 RPIT 方法(在 Rust 1.75 稳定)不可 dyn 分发
  • 每个 RPIT 都是不同的不透明类型(注意 TAIT 可绕过此限制)

RPIT 在类型参数和生命周期捕获方面还有一些相当棘手的行为。计划中的 impl Trait 功能值得独立于 dyn Trait 单独探讨,此处仅简要提及:

尽管有这些缺点,我认为在适用时,RPIT 在返回位置上对 dyn Trait 有轻微优势,尤其对拥有类型。一旦(命名的)TAIT 可用,dyn Trait 与 TAIT 之间的优势将更大:

  • 可以为返回类型命名并在多处复用
  • TAIT 天然具有精确捕获
    • 即你对哪些生命周期和类型参数被捕获有控制且显式

但 dyn Trait 有时仍是更好选择,例如:

  • 需要类型擦除并返回不同类型时
  • 需要 trait 对象安全性时

然而,通常还有第三种可能,我们在下文探讨:返回泛型 struct。

两者的另一种替代:名义泛型 struct

此处我们可以从标准库汲取灵感。使用 RPIT 或返回 dyn Trait 的常见场景之一是处理迭代器(因为迭代器链类型很长,且常涉及不可命名类型如闭包)。 因此让我们看看 Iterator 的方法。

你可能注意到组合器上的模式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
fn chain<U>(self, other: U) -> Chain<Self, <U as IntoIterator>::IntoIter>
where
    Self: Sized,
    U: IntoIterator<Item = Self::Item>,
{ todo!() }

fn filter<P>(self, predicate: P) -> Filter<Self, P>
where
    Self: Sized,
    P: FnMut(&Self::Item) -> bool,
{ todo!() }

fn map<B, F>(self, f: F) -> Map<Self, F>
where
    Self: Sized,
    F: FnMut(Self::Item) -> B,
{ todo!() }

模式是:由泛型类型参数化的函数返回同样由该泛型参数化的具体(名义)struct。即使参数本身不可命名也可行——例如 map 中,F: FnMut(Self::Item) -> B 参数很可能就是不可命名的闭包。

缺点是若自行遵循此模式,样板代码会多得多:必须定义 struct,并(对此类示例)为它们实现 Iterator trait,可能还有 DoubleEndedIterator 等其他 trait。这可能涉及存储原始迭代器并对其调用 next().map(|item| ...) 或类似操作。

好处是你(和方法的使用者)获得 RPIT 和 dyn Trait 的许多优点:

  • 无动态分发代价
  • 无装箱代价
  • 无针对具体类型的优化损失
  • 无单一 trait 限制
  • 无 dyn 兼容性限制
  • 适用于 trait
  • 能明确指定捕获
  • 能在 API 约束内更改实现
  • 可命名的返回类型

仍保留一些缺点:

  • 自动 trait 仍会泄漏,与 RPIT 一样存在 semver 风险
  • 多种具体类型不可能(除非也使用类型擦除),与 RPIT 一样

也会带来一些独特缺点:

  • 数据类型的变异性也会泄漏
  • 不可命名且非输入类型参数的类型无法支持(除非也使用类型擦除)

总体而言,当可以使用名义类型时,对函数使用者而言是最佳选择。但对函数实现者而言工作量也最大。

若可能,我推荐对通用库(即面向广泛使用者)采用名义类型,遵循标准库的做法。

泛型 struct

上一节我们讨论了泛型 struct 如何常作为 RPIT 或以某种形式返回 dyn Trait 的替代。相关问题是:何时应在数据类型内使用类型擦除?

在数据类型中使用类型擦除的主要原因是:希望将 trait 的实现者当作同一类型处理,例如在存储回调集合时。此时使用类型擦除是功能需求,并非真正有多少选择。

然而,你也可能想在数据类型中使用类型擦除以使自己的 struct 非泛型。毕竟当数据类型是泛型时,若以参数承担多种类型的方式使用你的数据类型,使用者必须自行传播泛型,或面临自行类型擦除你的数据类型的抉择。

这不仅关乎易用性,也关乎编译时乃至运行时性能。通过让所有方法单态化而编译更多代码,自然会倾向于更长的编译时间,代码体积增加有时在运行时可能比在合适位置少量动态分发更慢。

遗憾的是,在泛型与类型擦除之间选择没有银弹。不过一般原则是:对优化敏感、调用频繁的区域不应类型擦除,而将类型擦除推到繁重计算之外的边界。

例如,在紧循环中未能去虚拟化并内联对 <dyn Iterator>::next 的调用可能有相对大的影响;而偶尔触发并分发到已优化、未类型擦除实现的动态回调,则可能完全察觉不到。

enum

最后我们提及类型擦除的另一种替代:把所有实现类型放进一个 enum!

这显然只适用于你预期实现 trait 的固定类型集合。使用 enum 的缺点是常涉及大量样板代码,因为你经常要检查是哪个变体,而不是依赖动态分发替你完成。

好处是几乎避免了类型擦除及其他替代(如不透明类型)的所有缺点。

宏可以减轻此类样板代码的痛苦,生态中也有 旨在减少样板代码的 crate。

事实上,也有针对整个模式的 crate。

特别是,若你发现自己选择使用 dyn Any 但对已知类型集合进行大量向下转型尝试,应强烈考虑直接使用 enum。它不会更不 ergonomic(若有差别),且会更高效。

最后修改 August 23, 2026: 更新 (499855b16)