4.6 dyn Trait 与替代方案
8 分钟阅读
译文 · 基于 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)时,指的是如下函数:
| |
也就是说,将 impl Trait 用作函数的参数类型。
截至目前,APIT 在大多数情况下与泛型参数相同:
| |
主要区别在于泛型:
- 允许函数作者引用
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 的权衡
这里我们要在如下签名之间做出选择:
| |
当函数具有泛型参数时,该参数会为每次用于调用该函数的具体类型进行单态化(在生命周期擦除之后)。也就是说,参数所承担的每种类型都会在编译后的代码中产生一个不同的函数。(其中一些结果函数在可能时可能被优化消除或合并。)根据调用方式的不同,foo1 和 bar1 可能有许多副本。
但在(生命周期擦除之后),dyn Trait 是一种单一的具体类型。foo2 和 bar2 只会有一个副本。
然而在典型的 Rust 程序中,泛型参数优先于 dyn Trait 参数。原因如下:
- 每个单态化函数通常可以更好地优化
- Trait 约束比
dyn Trait更通用- 无
dyn兼容性顾虑(T: Clone可行) - 无单一 trait 限制(允许
T: Trait1 + Trait2)
- 无
- 更少通过动态分发的间接层
- 拥有类型情况下无需装箱
Box在#![no_std]程序中甚至不可用
dyn Trait 版本具有以下优势:
- 更小的代码体积
- 更快的代码生成
- 不会使 trait 变为
dyn不兼容
总体而言,除非有特定理由在参数位置选择 dyn Trait,应优先使用泛型。
返回位置 impl Trait 与 TAIT
预备知识:什么是返回位置 impl Trait 和 TAIT?
当我们谈论返回位置的 impl Trait(即 RPIT)时,指的是如下函数:
| |
与 APIT 不同,
RPIT 与泛型类型参数并不相同。它们是不透明类型别名或不透明类型别名构造器。在上例中,RPIT 是依赖于函数输入类型参数(T)的不透明类型别名构造器。对于每个具体的 T,RPIT 也是单一具体类型的别名。
函数体和编译器仍知道具体类型是什么,但对调用方和其他代码这是不透明的。相反,你能使用该类型的方式仅限于与 impl Trait 中的 trait 兼容的方式,加上具体类型碰巧实现的任何自动 trait。(或由此可证明的性质,例如 blanket trait 实现。)
「单一」是关键:以下代码无法编译,因为它试图返回两种不同类型。Rust 是严格静态类型语言,这不可能——RPIT 的不透明性不能也不应改变这一点。
| |
type 别名 impl Trait(TAIT)是 RPIT 的推广,尚未稳定, 但可能在不太久的将来稳定。TAIT 允许为不透明类型定义别名,使其可被命名并在多处使用。
| |
概念上(也希望实际上),RPIT 会类似地脱糖为 TAIT:
| |
TAIT 仍必须是单一具体类型的别名。
从调用方角度看,不透明类型的一般缺点还包括:
- 不透明类型对其所有参数都是不变的,而名义 struct 不必如此
- 任何 trait(包括局部 trait)都不能在不透明类型上实现
- 名义 struct 的所有 trait 实现、公共字段和公共固有方法都可用,而不透明类型揭示的信息少得多
RPIT 与 dyn Trait 的权衡
RPIT 与 dyn Trait 返回对函数作者共享一些好处:
- 只要约束不变,可以更改具体或基类型
- 可以返回不可命名类型,例如闭包
- 简化复杂类型,例如长迭代器组合器链
dyn Trait 确实有一些限制和缺点:
- 没有子 trait 样板代码,只能支持一个非自动 trait
- 相比之下,RPIT 可以返回
impl Trait1 + Trait2
- 相比之下,RPIT 可以返回
- 只支持
dyn兼容的 trait- 相比之下,RPIT 可以返回
impl Clone
- 相比之下,RPIT 可以返回
- 返回拥有类型必须以某种形式装箱
- 承担不知道基类型和执行动态分发的典型优化代价
然而 RPIT 也有缺点:
- 作为不透明别名,只能返回一种实际的具体类型
- 目前返回类型不可命名,对使用者可能别扭
- 例如无法将结果作为非泛型字段存入 struct
- ……除非不透明类型约束是
dyn兼容的,你可以自行类型擦除
- 自动 trait 会泄漏,函数作者很容易意外破坏 SemVer
- 而
dyn Trait中自动 trait 是显式的
- 而
- trait 中的 RPIT 方法(在 Rust 1.75 稳定)不可
dyn分发 - 每个 RPIT 都是不同的不透明类型(注意 TAIT 可绕过此限制)
RPIT 在类型参数和生命周期捕获方面还有一些相当棘手的行为。计划中的 impl Trait 功能值得独立于 dyn Trait 单独探讨,此处仅简要提及:
- RPIT 默认捕获所有类型和生命周期参数
- 尽管精确捕获可在一定程度上缓解
- RPIT 捕获特定生命周期,而非所有生命周期的交集
- 因此繁琐地捕获输入生命周期的交集而非并集
尽管有这些缺点,我认为在适用时,RPIT 在返回位置上对 dyn Trait 有轻微优势,尤其对拥有类型。一旦(命名的)TAIT 可用,dyn Trait 与 TAIT 之间的优势将更大:
- 可以为返回类型命名并在多处复用
- TAIT 天然具有精确捕获
- 即你对哪些生命周期和类型参数被捕获有控制且显式
但 dyn Trait 有时仍是更好选择,例如:
- 需要类型擦除并返回不同类型时
- 需要 trait 对象安全性时
然而,通常还有第三种可能,我们在下文探讨:返回泛型 struct。
两者的另一种替代:名义泛型 struct
此处我们可以从标准库汲取灵感。使用 RPIT 或返回 dyn Trait 的常见场景之一是处理迭代器(因为迭代器链类型很长,且常涉及不可命名类型如闭包)。
因此让我们看看 Iterator 的方法。
你可能注意到组合器上的模式:
| |
模式是:由泛型类型参数化的函数返回同样由该泛型参数化的具体(名义)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(若有差别),且会更高效。