本文是 rust 社区大神 quinedot 的系列文章 的翻译版。我在国内各个平台似乎都没有看到现存的翻译,因此尝试做些搬运工作。由于本人的技术和翻译水平都非常有限,难免有错误,非常欢迎大家对不当之处进行指正。
这是本系列文章的第 3 篇。
1. 前言
在 rust 中,我们通常使用强制类型转换(coercion)来将一些类型转换为特征对象,下面是用函数形式写出的一些典型场景:
fn coerce_ref<'a, T: Trait + Sized + 'a>(t: &T ) -> &( dyn Trait + 'a) { t }
fn coerce_box<'a, T: Trait + Sized + 'a>(t: Box<T>) -> Box<dyn Trait + 'a> { t }
fn coerce_arc<'a, T: Trait + Sized + 'a>(t: Arc<T>) -> Arc<dyn Trait + 'a> { t }
在实践中,我们看到的代码往往不会这样繁杂,因为这里引入了一些显式生命周期和特征约束,而通常这些约束都是隐含的或者无需使用。例如,泛型类型参数上的 Sized
约束一般是隐含的,但上面的代码里我们显式地标出,强调正在讨论的 base type 都是定长类型(Sized
)。
假设特征 Trait
是对象安全(object safe)的,并且满足 T: 'a + Trait + Sized
,那么对于一些受支持的指针类型 Ptr
(如 &_
和 Box<_>
),我们就可以将 Ptr<T>
强制转换成 Ptr<dyn Trait + 'a>
。
如果我们想要一个 dyn Trait + Send + 'a
,自然我们也需要 T: Send
,对于任何其他自动特征(auto trait)也是如此。
在本文的其余部分,我们将研究这些典型案例之外的情况,以及强制转换的一些使用限制。
2. 关联类型(associate type)
当一个特征拥有一或多个非泛型的关联类型时,该特征的每个具体实现类型,都会为各关联类型选择一个唯一确定的、静态可知的类型。也就是说,我们可以把关联类型理解成某个 base type 加其实现的特征的 “输出”。
那么,为 dyn Trait
实现 Trait
时,应该如何确定含有的关联类型呢?这个问题没有唯一确定的答案,实际上,在这种情况下,关联类型会随着被擦除的 base type 变化。
这样一来,矛盾就出现了:这与我们最开始提到的“特征的实现者应该对每个关联类型唯一确定”的原则是相违背的。对于这个问题,一个简单粗暴的解决方法是:一旦特征 Trait
含有关联类型,那我们就索性禁止构建 dyn Trait
的特征对象。然而倘若真的引入这种规则,就会让特征对象的实用性大打折扣,rust 的设计者们无法接受这种做法。最终,rust 团队采用的方案是,让特征中的关联类型本质上成为 dyn Trait
类型构造器的具名参数(named parameter)。(回想一下本系列文章的第一篇,由于特征对象的生命周期,dyn Trait
本质上是一个类型构造器,dyn Trait + 'a
才是一个具体类型,只是 'a
往往被隐藏。)
例如,如果给出
trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
那么可以有:
dyn Iterator<Item = String> + '_
dyn Iterator<Item = i32> + '_
dyn Iterator<Item = f64> + '_
等等。在 dyn Trait<...>
中的关联类型必须解析为具体类型,以便 dyn Trait<...>
成为一个具体类型。
如果你在 Iterator
的实现中指定了 type Item = String
,那么很自然地,你就只能将类型强制转换为 dyn Iterator<Item = String>
了。下面的写法反映了关联类型特征约束的样式:
fn takes_string_iter<Iter>(i: Iter)
where
Iter: Iterator<Item = String>,
{
// ...
}
使用具名参数有许多好处。首先,一个类型和它拥有的具名参数往往具有很强的逻辑关联,例如具名参数能够清晰地表示一个 Iterator
返回的 Item
是什么(当然,这些关联类型需要命名得当)。其次,它还避免了在泛型列表中对多个关联类型进行排序,例如按字典序排列或是按声明顺序,尤其是后者,很容易出差错。
译注:如果不是具名参数,那么可能会出现这种情况:
trait Trait { type Assoc1; type Assoc2; type Assoc3; } // 如果采用下面这种语法,一旦关联类型数量较多,读者就很难立刻将
i32
String
&[u8]
与 Assoc1/2/3 对号入座 let d: Box= ...; // 并非真实语法 // 而如果采用具名参数的写法,就清晰得多: let d: Box = ...;
然而,这些具名参数必须在普通泛型类型参数(而它们是需要严格按顺序排列的)之后指定。
trait AssocAndParams<T, U> { type Assoc1; type Assoc2; }
// 特性的顺序类型参数必须按声明顺序(这里是 String 然后是 usize)。
// 之后是具名的关联类型参数,这些参数可以任意方式排序。
fn foo(d: Box<dyn AssocAndParams<String, usize, Assoc1 = i32, Assoc2 = u32>>)
->
Box<dyn AssocAndParams<String, usize, Assoc2 = u32, Assoc1 = i32>>
{
d
}
2.1. 让关联类型失去 dyn
可用性
从 Rust 1.72 版本开始,如果我们在关联类型中添加了 where Self: Sized
约束,则该关联类型便不再满足对象安全(Object safe),在本系列文章中我们称之为“不可 dyn
”(dyn
-unusable TODO:)。在这种情况下,虽然我们还是能够为其 base type 构造特征对象,但是在构造时不再需要为关联类型指定具名参数,且在特征对象中也不再能够使用这个关联类型。
trait Trait {
type Foo where Self: Sized;
fn foo(&self) -> Self::Foo where Self: Sized;
fn bar(&self) {}
}
impl Trait for i32 {
type Foo = ();
fn foo(&self) -> Self::Foo {}
}
impl Trait for u64 {
type Foo = f32;
fn foo(&self) -> Self::Foo { 0.0 }
}
// 不再需要 `dyn Trait<Foo = ()>`!
let mut a: &dyn Trait = &0_i32;
// 让特征对象指向另一 base type 时,无需让关联类型保持一致
a = &0_u64;
// 这会失败,因为类型未定义 (`dyn Trait` 不可 `Sized`)
// let _: <dyn Trait as Trait>::Foo = todo!();
在这种情况下,我们还是可以用具名参数的方式指定关联类型,不过会报 warning,而且即使这么做了 dyn Trait
也还是无法使用这个关联类型。请看编译器的 warning:
warning: unnecessary associated type bound for not object safe associated type
--> src/main.rs:24:23
|
24 | let mut a: &dyn Trait<Foo = ()> = &0_i32;
| ^^^^^^^^ help: remove this bound
|
= note: this associated type has a `where Self: Sized` bound. Thus, while the associated type can be specified, it cannot be used in any way, because trait objects are not `Sized`.
= note: `#[warn(unused_associated_type_bounds)]` on by default
事实上,这样做不仅没能改变 dyn Trait
无法使用该关联类型的现状,还会导致对其他类型的不兼容,从而进一步限制强制类型转换的可用性:
let mut a: &dyn Trait<Foo = ()> = &0_i32;
// 下面会导致编译不通过
a = &0_u64;
不过这也引入了一些有趣的可能性,例如为 Box<dyn Trait>
实现上面的 Trait
时(TODO:),下面的写法是可以通过编译的:
impl<T: Default> Trait for Box<dyn Trait<Foo = T>> {
type Foo = T;
fn foo(&self) -> Self::Foo {
T::default()
}
}
目前的 warning 写的是 "虽然可以指定关联类型,但无法以任何方式使用",但这个例子表明技术上并非如此。我认为这种用法只是没有被预料到。
对于这种场景,编译器不报 error 而是报 warning 的原因是:曾经有一段时间, rust 允许用户为关联类型添加 Self: Sized
约束,但仍然需要在 dyn Trait<..>
中显式命名关联类型。因此,将 warning 变成 error 会是一次破坏性变更。
鉴于其潜在的实用性,我认为至少应该修改这个 warning 的措辞,甚至考虑重新命名。
3. 嵌套的强制类型转换是不可行的
对非定长类型(unsized)的强制类型转换需要经过一个间接层(例如引用或 Box
)才可行,这是因为需要容纳指向虚表的宽指针,而且 rust 也不允许 move 一个非定长类型。
然而,如果有多个间接层嵌套,那么编译器就不能执行这种强制类型转换了。例如,我们不能将 Vec<Box<T>>
强制转换为 Vec<Box<dyn Trait>>
。这是因为 Box<T>
和 Box<dyn Trait>
有不同的内存布局,前者占用的内存空间就是一个指针的大小,而后者则是两个指针的大小。所以,整个 Vec
都需要重新分配内存以适应这样的变化:
fn convert_vec<'a, T: Trait + 'a>(v: Vec<Box<T>>) -> Vec<Box<dyn Trait + 'a>> {
v.into_iter().map(|bx| bx as _).collect()
}
通常情况下,非定长类型的强制类型转换会消耗(consume)掉原始的指针(引用、Box
等),并产生一个新的指针,而这在多层嵌套的场景下是无法发生的。
从编译器内部视角来说,哪些强制类型转换是可能的取决于 CoerceUnsized
特征,以及由编译器实现的 Unsize
特征,正如链接中的文档里讨论的那样。
3.1. 一些例外
事实上,有一些例外情况:对于一些特别的间接层,即使发生了嵌套,也依然可以进行非定长类型的强制类型转换。
如果你阅读了上面链接中的文章,会发现有些类型,比如 Cell
,以递归的方式实现了 CoerceUnsized
。这是因为这些类型(包括 Cell
,RefCell
,SyncUnsafeCell
,UnsafeCell
)与它们的泛型参数有相同的内存布局。因此,在外侧再包上一层 Cell
不会被视为是“嵌套”的。
// 失败 :-(
//fn coerce_vec<'a, T: Trait + 'a>(v: Vec<Box>) -> Vec<Box<dyn Trait + 'a>> {
// v
//}
// 成功!:-)
fn coerce_cell<'a, T: Trait + 'a>(c: Cell<Box>) -> Cell<Box<dyn Trait + 'a>> {
c
}
我们将在接下来的章节(TODO:)讲解那些表面上的例外情况(其实只是 supertype 强制转换)。
4. Sized
的限制
任何一个 base type ,必须是定长类型(Sized
),才能被强制类型转换为 dyn Trait
。例如,尽管 str
实现了 Display
,但由于它是非定长类型(unsized), &str
便不能被强制转换为 &dyn Display
。
为什么会有这个限制呢?事实上,&str
本身也是一个宽指针,它包含一个指向 UTF8 字节的指针,和一个 usize
,表示字节的数量。类似地,切片引用 &[T]
包含一个指向连续数据的指针,和一个表示元素数量的计数。
由此观之,如果要从 &str
或 &[T]
创建特征对象,那么这个特征对象的内存布局似乎需要变成一个含有三个成员的“超宽指针”:一个指向数据的指针、一个元素计数和一个虚表指针。但对于编译器而言,&dyn Trait
是一种具体的类型,因此它有着确定的结构,只包含两个指针。所以,“超宽指针”这种 naive 的设想是不可行的。而且,这种设想还有另一个问题:如果我们想对一个“超宽指针”进行强制类型转换,那会发生什么呢?可以想象,每次进行递归的转换时都会引入一个新的指针,使得其内存变得无限大。
一种不那么 naive 的方法是在动态分发机制上做改动,对这些 Unsized 的 base type 做专门的处理。举个例子,在类型转换过程中,一旦擦除了 str
类型,我们就丢失了一些信息:例如 &str
也是一种宽指针,以及创建这种宽指针的方法。然而,编译器又需要重新创建一个宽指针,才能进行动态分发。
因此,如果要让编译器支持构建 Unsized 的特征对象,那么在构建指向 base type 的指针时,事情就会变得很复杂。首先,我们需要为普通的窄指针设计一种处理逻辑;其次,对于想要支持的每种宽指针类型,我们都要额外引入一段专门的处理逻辑。不止如此,关键的元数据(比如 str
的长度)也必须找地方存放起来,而且还不能像虚表那样放在内存里的 .data 段(静态内存)中。
这将会是地狱般的复杂,因此 rust 的设计者们决定索性不支持 Unsized 的 base type 了。
不过有时候,你可以通过例如为 &str
而不是 str
实现特征,然后将 &'_ str
强制类型转换为 dyn Trait + '_
来绕过这个限制,因为引用是一种定长类型。
// 这段代码不能通过编译,因为 str 是非定长类型,
// 所以我们不能将 str 强制转换为 dyn Display,
// 因而无法将 &str 强制转换为 &dyn Display。
// let _: &dyn Display = "hi";
// 然而,&str 也实现了 Display。(如果有 T: Display,那么 &T: Display)。
// 由于 &str 是定长类型(Sized),我们就能将 &&str 强制转换为 &dyn Display:
let _: &dyn Display = &"hi";
定长类型(Sized
)也被用作某种“非-dyn”的标识,这一点我们稍后(TODO:)会进一步探讨。
在 dyn Trait
自身的不同形式间进行强制类型转换时,会出现一种常见的异常,而它正是与 Size
限制有关。我们会在下文讨论这个情况。
5. 删除自动特征(auto trait)
我们可以将 dyn Trait + Send
强制转换为 dyn Trait
,并以类似的方式丢弃任何其他自动特征。
虽然 dyn Trait
不是 dyn Trait + Send
的超类型(super type),但这仍然被称为将 dyn Trait + Send
向上转换(upcasting)为 dyn Trait
。
请注意,自动特征不拥有方法,因此这些强制类型转换无需对虚表进行任何更改。这样一来,rust 允许用于从一个较严格的函数(例如要求 dyn Trait + Send
的函数)调用一个限制较少的函数(接受 dyn Trait
的函数)。这种强制类型转换是必要的,因为它们是不同的具体类型,而不是泛型、子类型或动态类型。
不过,即使不需要修改虚表,这种强制类型转换依然不可用于嵌套场景。
6. 反身转换(reflexive)
下面我要说一条看起来是废话的规则:你可以将 dyn Trait
强制类型转换为 dyn Trait
。
实际上它的本意是:你可以将 dyn Trait + 'a
强制转换为 dyn Trait + 'b
,其中 'a: 'b
。这对于 dyn Trait + '_
的借用机制起到了重要作用。
在编译过程中,生命周期会被擦除,因此虚表在任何生命周期下都是相同的。尽管如此,这种 Unsized 的强制类型转换依然不可用于嵌套场景。
然而在未来的章节(TODO:)中,我们将看到,即使在嵌套的情况下,协变(variance)也可以缩短特征对象的生命周期,前提是该嵌套也是协变的。紧接着的关于高阶类型的章节(TODO:)将探讨另一种与生命周期相关的强制转换,也可以被视为是反身的。
7. 超类型的 upcast
虽然目前在 rust 稳定版中还不支持,但从 dyn SubTrait
向 dyn SuperTrait
进行向上转换(upcast)的功能预计在未来某一天会实现。
再次强调,这明确是一种强制类型转换,而非子类型与超类型的关系(尽管术语如此)。尽管这是一个具体的实现细节,但与前几个示例相反,这种转换可能会涉及替换虚表指针。
在该功能尚未稳定之前,你可以手动实现超类型向上转换逻辑。
8. 仅适用于对象安全的特征
在使用特征对象时,对于特征还有其他限制,我们在这里尚未讨论,比如目前还不支持具有关联泛型类型(GATs, Generic Associated Types)的特征。我们在下一节(TODO:)中会详细介绍这些内容。