基础篇 (11讲)

10|再探trait:带类型参数的trait及trait object

回顾:类型参数出现的地方。

用 trait 对 T 作类型空间的约束,比如T: TraitA。blanket implementation 时的 T,比如 impl TraitA for T {}。函数里的 T 参数,比如 fn doit(a: T) {}。

你要注意区分不同位置的 T。它的基础意义都是类型参数,但是放在不同的位置其侧重的意义有所不同。

T: TraitA 里的 T 表示类型参数,强调“参数”,使用 TraitA 来削减它的类型空间。impl TraitA for T {} 末尾的 T 更强调类型参数的“类型”部分,为某些类型实现 TraitA。doit(a: T) {} 中第二个 T 表示某种类型,更强调类型参数的“类型”部分。

这节课我们要讲的是另外一个东西,它里面也带 T 参数。我们一起来看一下,它与之前这几种形式有什么不同。

trait 上带类型参数

trait 上也是可以带类型参数的,形式像下面这样:

        trait TraitA {}

表示这个 trait 里面的函数或方法,可能会用到这个类型参数。在定义 trait 的时候,还没确定这个类型参数的具体类型。要等到 impl 甚至使用类型方法的时候,才会具体化这个 T 的具体类型。

注意,这个时候 TraitA 是一个整体,表示一个 trait。比如  TraitA 和 TraitA 就是两个不同的 trait,这里单独把 TraitA 拿出来说是没有意义的。

实现时需要在 impl 后面先定义类型参数,比如:

        impl TraitA for Atype {}

当然也可以在对类型实现时,将 T 参数具体化,比如:

        impl TraitA for Atype {}

而如果被实现的类型上自身也带类型参数,那么情况会更复杂。

trait TraitA {}

struct Atype {

a: U,

}

impl TraitA for Atype {}

这些类型参数都是可以在 impl 时被约束的,像下面这样:

use std::fmt::Debug;

trait TraitA {}

struct Atype {

a: U,

}

impl TraitA for Atype

where

T: Debug, // 在 impl 时添加了约束

U: PartialEq, // 在 impl 时添加了约束

{}

注:以上代码都是可以放到 playground 中编译通过的。

impl 示例

下面我们通过一个具体的实例体会一下带类型参数的 trait 的威力。

我们现在要实现一个模型。

平面上的一个点与平面上的另一个点相加,形成一个新的点。算法是两个点的 x 分量和 y 分量分别相加。

平面上的一个点加一个整数 i32,形成一个新的点。算法是分别在 x 分量和 y 分量上面加这个 i32 参数。

本示例借鉴了:https://github.com/pretzelhammer/rust-blog/blob/master/posts/tour-of-rusts-standard-library-traits.md#generic-types-vs-associated-types

代码如下:

// 定义一个带类型参数的trait

trait Add {

type Output;

fn add(self, rhs: T) -> Self::Output;

}

struct Point {

x: i32,

y: i32,

}

// 为 Point 实现 Add 这个 trait

impl Add for Point {

type Output = Self;

fn add(self, rhs: Point) -> Self::Output {

Point {

x: self.x + rhs.x,

y: self.y + rhs.y,

}

}

}

// 为 Point 实现 Add 这个 trait

impl Add for Point {

type Output = Self;

fn add(self, rhs: i32) -> Self::Output {

Point {

x: self.x + rhs,

y: self.y + rhs,

}

}

}

fn main() {

let p1 = Point { x: 1, y: 1 };

let p2 = Point { x: 2, y: 2 };

let p3 = p1.add(p2); // 两个Point实例相加

assert_eq!(p3.x, 3);

assert_eq!(p3.y, 3);

let p1 = Point { x: 1, y: 1 };

let delta = 2;

let p3 = p1.add(delta); // 一个Point实例加一个i32

assert_eq!(p3.x, 3);

assert_eq!(p3.y, 3);

}

详解一下。

Add 这个 trait,带一个类型参数 T,还带一个关联类型 Output。

对 Point 类型,我们实现了两个 trait:Add 和 Add。注意这已经是两个不同的 trait 了,所以能对同一个类型实现。前面我们反复强调过,同一个 trait 只能对一个类型实现一次。

根据需求,运算后的类型也是 Point,所以看到两个 trait 中的关联类型都是 Self。请注意两个 trait 中实现的不同算法。

通过这种形式,我们在同一个类型上实现了同名方法(add 方法)参数类型的多种形态。在这里看起来就是,Point 实例的 add 方法既可以接收 Point 参数,又可以接收 i32 参数,Rustc 小助手可以根据不同的参数类型自动找到对应的方法调用。在 Java、C++ 这些语言中,有语言层面的函数重载特性来支持这种功能,Rust 中自身并不直接支持函数重载特性,但是它用 trait 就轻松实现了同样的效果,这是一种全新的思路。

trait 类型参数的默认实现

定义带类型参数的 trait 的时候,可以为类型参数指定一个默认类型,比如 trait TraitA {}。这样使用时,impl TraitA for SomeType {} 就等价于 impl TraitA for SomeType {}。

完整的例子。

// Self可以用在默认类型位置上

trait TraitA {

fn func(t: T) {}

}

// 这个默认类型为i32

trait TraitB {

fn func2(t: T) {}

}

struct SomeType;

// 这里省略了类型参数,所以这里的T为Self

// 进而T就是SomeType本身

impl TraitA for SomeType {

fn func(t: SomeType) {}

}

// 这里省略了类型参数,使用默认类型i32

impl TraitB for SomeType {

fn func2(t: i32) {}

}

// 这里不省略类型参数,明确指定类型参数为String

impl TraitA for SomeType {

fn func(t: String) {}

}

// 这里不省略类型参数,明确指定类型参数为String

impl TraitB for SomeType {

fn func2(t: String) {}

}

默认参数给表达上带来了一定程度的简洁,但是增加了初学者识别和理解上的困难。

你还记得上一节课讲关联类型时我们提到过在使用约束时可以具化关联类型。那里也是用的=号。比如:

trait TraitA {

type Item;

}

// 这里,定义结构体类型时,用到了TraitA作为约束

struct Foo> {

x: T

}

初看这里容易混淆。区别在于,关联类型的具化是在应用约束时,类型参数的默认类型指定是在定义 trait 时,通过 trait 出现的场景可以区分它们。

trait 中的类型参数与关联类型的区别

现在你可能会有些疑惑:trait 上的类型参数和关联类型都具有延迟具化的特点,那么它们的区别是什么呢?为什么要设计两种不同的机制呢?

首先要明确的一点是,Rust 本身也在持续演化过程中。有些特性先出现,有些特性是后出现的。最后演化出功能相似但是不完全一样的特性是完全有可能的。

具体到这两者来说,它们主要有两点不同。

类型参数可以在 impl 类型的时候具化,也可以延迟到使用的时候具化。而关联类型在被 impl 时就必须具化。由于类型参数和 trait 名一起组成了完整的 trait 名字,不同的具化类型会构成不同的 trait,所以看起来同一个定义可以在目标类型上实现“多次”。而关联类型没有这个作用。

下面我们分别举例说明。对于第一点,请看下面的示例:

use std::fmt::Debug;

trait TraitA

where

T: Debug, // 定义TraitA的时候,对T作了约束

{

fn play(&self, _t: T) {}

}

struct Atype;

impl TraitA for Atype

where

T: Debug + PartialEq, // 将TraitA实现到类型Atype上时,加强了约束

{}

fn main() {

let a = Atype;

a.play(10u32); // 在使用时,通过实例方法传入的参数类型具化T

}

这个示例展示了几个要点。

定义带类型参数的 trait 时可以用 where 表达,并提供约束。impl trait 时可以对类型参数加强约束,对应例子中的 Debug + PartialEq。impl trait 时可以不具化类型参数。可以在使用方法时具化类型参数。例子里的 a.play(10u32),把 T 具象化成了 u32。

当然,在 impl 的时候也可以指定成 u32 类型,所以下面的代码也可以。

use std::fmt::Debug;

trait TraitA

where

T: Debug,

{

fn play(&self, _t: T) {}

}

struct Atype;

impl TraitA for Atype {} // 这里具化成了 TraitA

fn main() {

let a = Atype;

a.play(10u32);

}

但是这样就没前面那么灵活了,比如 a.play(10u64) 就不行了。

对应的,对关联类型来说,如果你在 impl 时不对其具化,就无法编译通过。所以对于第二点,我也给出一个例子来解释。我们把前面对 Point 类型实现 Add 的模型尝试用关联类型实现一遍。

trait Add {

type ToAdd; // 多定义一个关联类型

type Output;

fn add(self, rhs: Self::ToAdd) -> Self::Output;

}

struct Point {

x: i32,

y: i32,

}

impl Add for Point {

type ToAdd = Point;

type Output = Point;

fn add(self, rhs: Point) -> Point {

Point {

x: self.x + rhs.x,

y: self.y + rhs.y,

}

}

}

impl Add for Point { // 这里重复impl了同一个trait,无法编译通过

type ToAdd = i32;

type Output = Point;

fn add(self, rhs: i32) -> Point {

Point {

x: self.x + rhs,

y: self.y + rhs,

}

}

}

fn main() {

let p1 = Point { x: 1, y: 1 };

let p2 = Point { x: 2, y: 2 };

let p3 = p1.add(p2);

assert_eq!(p3.x, 3);

assert_eq!(p3.y, 3);

let p1 = Point { x: 1, y: 1 };

let delta = 2;

let p3 = p1.add(delta); // 这句是错的

assert_eq!(p3.x, 3);

assert_eq!(p3.y, 3);

//

error[E0119]: conflicting implementations of trait `Add` for type `Point`:

--> src/main.rs:23:1

|

12 | impl Add for Point {

| ------------------ first implementation here

...

23 | impl Add for Point {

| ^^^^^^^^^^^^^^^^^^ conflicting implementation for `Point`

提示说,对 Point 类型实现了多次 Add,导致冲突。编译不通过。所以这个模型仅用关联类型来实现,是写不出来的。

这么看起来,好像带类型参数的 trait 功能更强大,那用这个不就够了?但关联类型也有它的优点,比如关联类型没有类型参数,不存在多引入了一个参数的问题,而类型参数是具有传染性的,特别是在一个调用层次很深的系统中,增删一个类型参数可能会导致整个项目文件到处都需要改,非常头疼。

而关联类型没有这个问题。在一些场合下,关联类型正好是减少类型参数数量的一种方法。更不要说,有时模型比较简单,不需要多态特性,这时用关联类型就更简洁,代码可读性更好。

trait object

下面我们开始讲 trait object。

我们从一个函数要返回不同的类型说起。比如一个常见的需求,要在一个 Rust 函数中返回可能的多种类型,应该怎么写?

如果我们写成返回固定类型的函数签名,那么它就只能返回那个类型。比如:

struct Atype;

struct Btype;

struct Ctype;

fn doit() -> Atype {

let a = Atype;

a

}

你想到的第一个办法可能是利用 enum。

struct Atype;

struct Btype;

struct Ctype;

enum TotalType {

A(Atype), // 用变体把目标类型包起来

B(Btype),

C(Ctype),

}

fn doit(i: u32) -> TotalType { // 返回枚举类型

if i == 0 {

let a = Atype;

TotalType::A(a) // 在这个分支中返回变体A

} else if i == 1 {

let b = Btype;

TotalType::B(b) // 在这个分支中返回变体B

} else {

let c = Ctype;

TotalType::C(c) // 在这个分支中返回变体C

}

}

enum 常用于聚合类型。这些类型之间可以没有任何关系,用 enum 可以无脑 + 强行把它们揉在一起。enum 聚合类型是编码时已知的类型,也就是说在聚合前,需要知道待聚合类型的边界,一旦定义完成,之后运行时就不能改动了,它是封闭类型集。

第二种办法是利用类型参数,我们试着引入一个类型参数,改写一下。

struct Atype;

struct Btype;

struct Ctype;

fn doit() -> T {

let a = Atype;

a

}

// 很明显,这种代码无法通过编译。提示:

error[E0308]: mismatched types

--> src/lib.rs:6:3

|

4 | fn doit() -> T {

| - - expected `T` because of return type

| |

| this type parameter

5 | let a = Atype;

6 | a

| ^ expected type parameter `T`, found `Atype`

|

= note: expected type parameter `T`

found struct `Atype`

因为这里这个类型参数 T 是在这个函数调用时指定,而不是在这个函数定义时指定的。所以针对我们的需求,你没法在这里直接返回一个具体的类型代入 T。只能尝试用 T 来返回,于是我们改出第二个版本。

struct Atype;

struct Btype;

struct Ctype;

impl Atype {

fn new() -> Atype {

Atype

}

}

impl Btype {

fn new() -> Btype {

Btype

}

}

impl Ctype {

fn new() -> Ctype {

Ctype

}

}

fn doit() -> T {

T::new()

}

// 编译还是报错。

error[E0599]: no function or associated item named `new` found for type parameter `T` in the current scope

--> src/main.rs:24:6

|

23 | fn doit() -> T {

| - function or associated item `new` not found for this type parameter

24 | T::new()

| ^^^ function or associated item not found in `T`

也就是说,Rustc 小助手并不知道我们定义这个类型参数 T 里面有 new 这个关联函数。联想到我们前面学过的,可以用 trait 来定义这个协议,让 Rust 认识它。

第三个版本:

struct Atype;

struct Btype;

struct Ctype;

trait TraitA {

fn new() -> Self; // TraitA中定义了new()函数

}

impl TraitA for Atype {

fn new() -> Atype {

Atype

}

}

impl TraitA for Btype {

fn new() -> Btype {

Btype

}

}

impl TraitA for Ctype {

fn new() -> Ctype {

Ctype

}

}

fn doit() -> T {

T::new()

}

fn main() {

let a: Atype = doit::();

let b: Btype = doit::();

let c: Ctype = doit::();

}

这个版本顺利通过编译。在这个示例中,我们认识到了引入 trait 的必要性,就是让 Rustc 小助手知道我们在协议层面有一个 new() 函数,一旦类型参数被 trait 约束后,它就可以去 trait 中寻找协议定义的函数和方法。

为了解决上面那个问题,我们真的是费了不少力气。实际上,Rust 提供了更优雅的方案来解决这个需求。Rust 利用 trait 提供了一种特殊语法 impl trait,你可以看一下示例。

struct Atype;

struct Btype;

struct Ctype;

trait TraitA {}

impl TraitA for Atype {}

impl TraitA for Btype {}

impl TraitA for Ctype {}

fn doit() -> impl TraitA { // 注意这一行的函数返回类型

let a = Atype;

a

// 或

// let b = Btype;

// b

// 或

// let c = Ctype;

// c

}

可以看到,这种表达非常简洁,同一个函数签名可以返回多种不同的类型,并且在函数定义时就可以返回具体的类型的实例。更重要的是消除了类型参数 T。

上述代码已经很有用了,但是还是不够灵活,比如我们要用 if 逻辑选择不同的分支返回不同的类型,就会遇到问题。

struct Atype;

struct Btype;

struct Ctype;

trait TraitA {}

impl TraitA for Atype {}

impl TraitA for Btype {}

impl TraitA for Ctype {}

fn doit(i: u32) -> impl TraitA {

if i == 0 {

let a = Atype;

a // 在这个分支中返回类型a

} else if i == 1 {

let b = Btype;

b // 在这个分支中返回类型b

} else {

let c = Ctype;

c // 在这个分支中返回类型c

}

}

// 提示

error[E0308]: `if` and `else` have incompatible types

--> src/lib.rs:22:5

|

17 | } else if i == 1 {

| __________-

18 | | let b = Btype;

19 | | b

| | - expected because of this

20 | | } else {

21 | | let c = Ctype;

22 | | c

| | ^ expected `Btype`, found `Ctype`

23 | | }

| |___- `if` and `else` have incompatible types

if else 要求返回同一种类型,Rust 检查确实严格。不过我们可以通过加 return 跳过 if else 的限制。

struct Atype;

struct Btype;

struct Ctype;

trait TraitA {}

impl TraitA for Atype {}

impl TraitA for Btype {}

impl TraitA for Ctype {}

fn doit(i: u32) -> impl TraitA {

if i == 0 {

let a = Atype;

return a; // 这里用return语句直接从函数返回

} else if i == 1 {

let b = Btype;

return b;

} else {

let c = Ctype;

return c;

}

}

// 还是报错

error[E0308]: mismatched types

--> src/lib.rs:19:12

|

13 | fn doit(i: u32) -> impl TraitA { // 这一行

| ----------- expected `Atype` because of return type

...

19 | return b

| ^ expected `Atype`, found `Btype`

它说期望 Atype,却得到了 Btype。这个报错其实有点奇怪,它们不是都满足 impl TraitA 吗?

原来问题在于,impl TraitA 作为函数返回值这种语法,其实也只是指代某一种类型而已,而这种类型是在函数体中由返回值的类型来自动推导出来的。例子中,Rustc 小助手遇到 Atype 这个分支时,就已经确定了函数返回类型为 Atype,因此当它分析到后面的 Btype 分支时,就发现类型不匹配了。问题就在这里。你可以将条件分支顺序换一下,看一下报错的提示,加深印象。

那我们应该怎么处理这种问题呢?

好在,Rust 还给我们提供了进一步的措施:trait object。形式上,就是在 trait 名前加 dyn 关键字修饰,在这个例子里就是 dyn TraitA。dyn TraitName 本身就是一种类型,它和 TraitName 这个 trait 相关,但是它们不同,dyn TraitName 是一个独立的类型。

我们使用 dyn TraitA 改写上面的代码。

struct Atype;

struct Btype;

struct Ctype;

trait TraitA {}

impl TraitA for Atype {}

impl TraitA for Btype {}

impl TraitA for Ctype {}

fn doit(i: u32) -> dyn TraitA { // 注意这里的返回类型换成了 dyn TraitA

if i == 0 {

let a = Atype;

return a

} else if i == 1 {

let b = Btype;

return b

} else {

let c = Ctype;

return c

}

}

// 但是编译会报错。

error[E0746]: return type cannot have an unboxed trait object

--> src/lib.rs:13:20

|

13 | fn doit(i: u32) -> dyn TraitA {

| ^^^^^^^^^^ doesn't have a size known at compile-time

|

help: return an `impl Trait` instead of a `dyn Trait`, if all returned values are the same type

|

13 | fn doit(i: u32) -> impl TraitA {

| ~~~~

help: box the return type, and wrap all of the returned values in `Box::new`

|

13 ~ fn doit(i: u32) -> Box {

14 | if i == 0 {

15 | let a = Atype;

16 ~ return Box::new(a)

17 | } else if i == 1 {

18 | let b = Btype;

19 ~ return Box::new(b)

20 | } else {

21 | let c = Ctype;

22 ~ return Box::new(c)

这段提示很经典,我们来仔细阅读一下。

它说 dyn TraitA 编译时尺寸未知。dyn trait 确实不是一个固定尺寸类型。然后给出了第一个建议:你可以用 impl TraitA 来解决,前提是所有分支返回同一类型。随后给出了第二个建议,你可以用 Box 把 dyn TraitA 包起来。

(‍:有没有 ChatGPT 的即时感,聪明得不太像一个编译器。)

第一个建议我们已经试过了,Pass,我们按照第二种建议改一下试试。

struct Atype;

struct Btype;

struct Ctype;

trait TraitA {}

impl TraitA for Atype {}

impl TraitA for Btype {}

impl TraitA for Ctype {}

fn doit(i: u32) -> Box {

if i == 0 {

let a = Atype;

Box::new(a)

} else if i == 1 {

let b = Btype;

Box::new(b)

} else {

let c = Ctype;

Box::new(c)

}

}

这下完美了,编译通过,达成目标,我们成功地将不同类型的实例在同一个函数中返回了。

这里我们引入了一个新的东西 Box。Box 的作用是可以保证获得里面值的所有权,必要的时候会进行内存的复制,比如把栈上的值复制到堆中去。一旦值到了堆中,就很容易掌握到它的所有权。

具体到这个示例中,因为 a、b、c 都是函数中的局部变量,这里如果返回引用 &dyn TraitA 的话是万万不能的,因为违反了所有权规则。而 Box 就能满足这里的要求。后续我们在智能指针一讲中会继续讲解 Box

这里我们先暂停,我希望你可以用一点时间来回顾一下整个推导过程,这次令人印象深刻的类型“体操”值得我们多品味几次。

利用 trait object 传参

impl trait 和 dyn trait 也可以用于函数传参。

impl trait 的示例:

struct Atype;

struct Btype;

struct Ctype;

trait TraitA {}

impl TraitA for Atype {}

impl TraitA for Btype {}

impl TraitA for Ctype {}

fn doit(x: impl TraitA) {}

// 等价于

// fn doit(x: T) {}

fn main() {

let a = Atype;

doit(a);

let b = Btype;

doit(b);

let c = Ctype;

doit(c);

}

dyn trait 的示例:

struct Atype;

struct Btype;

struct Ctype;

trait TraitA {}

impl TraitA for Atype {}

impl TraitA for Btype {}

impl TraitA for Ctype {}

fn doit(x: &dyn TraitA) {} // 注意这里用了引用形式 &dyn TraitA

fn main() {

let a = Atype;

doit(&a);

let b = Btype;

doit(&b);

let c = Ctype;

doit(&c);

}

两种都可以。那么它们的区别是什么呢?

impl trait 用的是编译器静态展开,也就是编译时具化(单态化)。上面那个 impl trait 示例展开后类似于下面这个样子。

struct Atype;

struct Btype;

struct Ctype;

trait TraitA {}

impl TraitA for Atype {}

impl TraitA for Btype {}

impl TraitA for Ctype {}

fn doit_a(x: Atype) {}

fn doit_b(x: Btype) {}

fn doit_c(x: Ctype) {}

fn main() {

let a = Atype;

doit_a(a);

let b = Btype;

doit_b(b);

let c = Ctype;

doit_c(c);

}

而 dyn trait 的版本不会在编译期间做任何展开,dyn TraitA 自己就是一个类型,这个类型相当于一个代理类型,用于在运行时代理相关类型及调用对应方法。既然是代理,也就是调用方法的时候需要多跳转一次,从性能上来说,当然要比在编译期直接展开一步到位调用对应函数要慢一点。

静态展开也有问题,就是会使编译出来的内容体积增大,而 dyn trait 就不会。所以它们各有利弊,可以根据需求视情况选择。另外,impl trait 和 dyn trait 都是消除类型参数的办法。

那它们和 enum 相比呢?

enum 是封闭类型集,可以把没有任何关系的任意类型包裹成一个统一的单一类型。后续的任何变动,都需要改这个统一类型,以及基于这个 enum 的模式匹配等相关代码。而 impl trait 和 dyn trait 是开放类型集。只要对新的类型实现 trait,就可以传入使用了 impl trait 或 dyn trait 的函数,其函数签名不用变。

上述区别对于库的提供者非常重要。如果你提供了一个库,里面的多类型使用的 enum 包装,那么库的使用者没办法对你的 enum 进行扩展。因为一般来说,我们不鼓励去修改库里面的代码。而用 impl trait 或 dyn trait 就可以让接口具有可扩展性。用户只需要给他们的类型实现你的库提供的 trait,就可以代入库的接口使用了。

而对于 impl trait 来说,它目前只能用于少数几个地方。一个是函数参数,另一个是函数返回值。其他的静态展开场景就得用类型参数形式了。

dyn trait 本身是一种非固定尺寸类型,这就注定了相比于 impl trait 它能应用于更多场合,比如利用 trait obj 把不同的类型装进集合里。

利用 trait obj 将不同的类型装进集合里

我们看下面的示例,我们想把三种类型装进一个 Vec 里面。

struct Atype;

struct Btype;

struct Ctype;

trait TraitA {}

impl TraitA for Atype {}

impl TraitA for Btype {}

impl TraitA for Ctype {}

fn main() {

let a = Atype;

let b = Btype;

let c = Ctype;

let v = vec![a, b, c];

}

// 报错

error[E0308]: mismatched types

--> src/main.rs:19:21

|

19 | let v = vec![a, b, c];

| ^ expected `Atype`, found `Btype`

因为 Vec 中要求每一个元素是同一种类型,不能将不同的类型实例放入同一个 Vec。而利用 trait object,我们可以“绕”过这个限制。

请看示例:

struct Atype;

struct Btype;

struct Ctype;

trait TraitA {}

impl TraitA for Atype {}

impl TraitA for Btype {}

impl TraitA for Ctype {}

fn main() {

let a = Atype;

let b = Btype;

let c = Ctype;

let v: Vec<&dyn TraitA> = vec![&a, &b, &c];

}

成功了,不同类型的实例(实际是实例的引用)竟然被放进了同一个 Vec 中,强大!你可以自己尝试一下,将不同类型的实例放入 HashMap 中。

既然 trait object 这么好用,那是不是可以随便使用呢?不是的。除了前面提到的性能损失之外,还有一个问题,不是所有的 trait 都可以做 dyn 化,也就是说,不是所有的 trait 都能转成 trait object 使用。

哪些 trait 能用作 trait object?

只有满足对象安全(object safety)的 trait 才能被用作 trait object。Rust 参考手册上有关于 object safety 的详细规则,比较复杂。这里我们了解常用的模式就行。

安全的 trait object:

// 安全的 trait object:

trait TraitA {

fn foo(&self) {}

fn foo_mut(&mut self) {}

fn foo_box(self: Box) {}

}

// 不安全的 trait object:

trait NotObjectSafe {

const CONST: i32 = 1; // 不能包含关联常量

fn foo() {} // 不能包含这样的关联函数

fn selfin(self); // 不能将Self所有权传入

fn returns(&self) -> Self; // 不能返回Self

fn typed(&self, x: T) {} // 方法中不能有类型参数

}

规则确实比较复杂,你可以简单记住几种场景。

不要在 trait 里面定义构造函数,比如 new 这种返回 Self 的关联函数。你可以发现,确实在整个 Rust 生态中都没有将构造函数定义在 trait 中的习惯。trait 里面尽量定义传引用 &self 或 &mut self 的方法,而不要定义传值 self 的方法。

并不是所有的 trait 都能以 trait object 形式(dyn trait)使用,实际上,以 dyn trait 使用的场景可能是少数。所以你可以在遇到编译器报错的时候再回头来审视 trait 定义得是否合理。大部分情况下可以放心使用。

小结

在这节课的前半部分,我们讲解了 trait 中带类型参数的情况。各种符号组合起来,确实越来越复杂了。不过还是那句话,模式就那几种,只要花点时间熟悉理解,其实并不难。开始的时候能认识就行,后面在实践中再慢慢掌握。

我们使用带类型参数的 trait 实现了其他语言中函数重载的功能。看起来途径有点曲折,但是带给了我们一条全新的思路:以往的语言必须给自身添加各种特性来满足用户的要求,在 Rust 中,用好 trait 就能搞定。这让我们对 Rust 的未来充满期待,随着时间的发展,它不会像 C++、Java 那样永不停歇地添加可能会导致组合爆炸的新特性,而让自身越来越臃肿。

我们还讨论了带类型参数的 trait 与关联类型的区别。它们之间并不存在绝对优势的一方,在合适的场景下选择合适的方案是最重要的。

然后我们通过一个问题:如何让一个 Rust 函数返回可能的多种类型?推导出了引入 trait object 方案的必要性。整个推导过程比较曲折,同时也是对 Rust 类型系统的一次精彩探索。在这个探索过程中,我们和 Rustc 小助手成为了好朋友,在它的协助下,我们找到了最佳方案。

最后我们了解了 trait object 的一些用途,并讨论了 trait object、impl trait,还有使用枚举对类型进行聚合这三种方式之间的区别。类型系统(类型 + trait)是 Rust 的大脑,你可以多加练习,熟悉它的形式,掌握它的用法。

思考题

请谈谈在函数参数中传入 &dyn TraitA 与 Box 两种类型的区别。

rust生命周期的独特设计,导致了该语言需要设计一些处理方式应对特殊情况,比如生命周期的标注(主要是给编译器进行代码处理时的提示)。事实上,我们在日常开发中应该避免一些陷入复杂情况的方式:比如,传入参数都用引用(borrow),传出结果都应该是owner。rust也为我们提供了处理各种情况的工具。所以,一般来说,我们应该在传入参数的时候用&dyn T,传出结果用Box

作者回复: 真不错

最后安全的trait object听的似懂非懂,为什么是安全的,为什么是不安全的?希望具体讲下原因

作者回复: 这里面的设计原理确实比较dirty了。我先给出三篇文章参考,你可以深入下去研究:https://rustcc.cn/article?id=e80a98d7-4adb-4d39-bf0a-4e079bcb1835 https://segmentfault.com/a/1190000022104780 https://rust-lang.github.io/rfcs/0255-object-safety.html

&dyn TraitA没有所有权,而Box有所有权。

作者回复: 对的

关联类型之所以要单独设计,因为编译器可以通过输入判断具体类型,而无法推断出输出类型,所以,输出的类型需要明确指定

作者回复: 思考方式很棒!

思考题:&dyn TraitA 是借用,Box 会转移所有权。

通过下面的程序可以测试出来:

fn doit3(t1: &dyn TraitA, t2: Box) {

println!("{:?}", t1);

println!("{:?}", t2)

}

fn main() {

let a = AType;

let b = BType;

doit3(&a, Box::new(b));

println!("{:?}", a);

println!("{:?}", b);

}

// 输出:

error[E0382]: borrow of moved value: `b`

--> examples/trait_object.rs:29:22

|

26 | let b = BType;

| - move occurs because `b` has type `BType`, which does not implement the `Copy` trait

27 | doit3(&a, Box::new(b));

| - value moved here

28 | println!("{:?}", a);

29 | println!("{:?}", b);

| ^ value borrowed here after move

作者回复: 

不要在 trait 里面定义构造函数,比如 new 这种返回 Self 的关联函数。你可以发现,确实在整个 Rust 生态中都没有将构造函数定义在 trait 中的习惯。 这句话,在上面的例子中 确实有在trait中定义了new函数返回self的。怎么就感觉有冲突呢?

作者回复: 观察得很细致,上面的例子主要是为了展现可能出现的写法。后面的说明加入了object safty的因素,以及社区的约定,所以看起来好像不一致,实际是一致的。

struct Atype;

struct Btype;

struct Ctype;

trait TraitA {}

impl TraitA for Atype {}

impl TraitA for Btype {}

impl TraitA for Ctype {}

fn doit(i: u32) -> &'static dyn TraitA { // 注意这里的返回类型换成了 dyn TraitA

if i == 0 {

return &Atype

} else if i == 1 {

return &Btype

} else {

return &Ctype

}

}

老师 我这种也能通过编译 ,我一开始尝试用&dyn TraitA没通过编译,小助手报错error[E0106]: missing lifetime specifier并提示我用&'static dyn TraitA,在字符串那节您提过 'static 表示这个引用可以贯穿整个程序的生命周期,想问下您这段代码为什么加了'static就可以通过编译,以及这个生命周期的概念又是怎么回事,谢谢了

作者回复: ,这种探索的方式很棒。'static 确实起这个作用。在 Rust 中,'static 生命期是整个程序的持续时间。当你在类型前面加上 'static 时,你告诉 Rust 编译器这个引用应该在整个程序运行期间都有效。 但是这并不意味着你可以返回一个指向局部变量的 'static 引用。这是因为局部变量在函数返回时就会被销毁,所以任何指向它们的引用在函数返回后都将变得无效。这个 'static 告诉 Rust 编译器这个引用应该在整个程序运行期间都有效,于是Rust就信了,即使实际上这是不可能的,所以还是要用Box。从这一点也能看出Rust编译器也不完美。 然后第20讲会讲生命期的概念,但是也不会特别深入。初学Rust的时候,先不用花大量时间在这上面,可以把这个问题留到后面去。

&dyn TraitA 是实现了TraitA的类型的对象的引用,Box则是获取了该对象的所有权。

作者回复: 。初学者可以这样理解。最重要的是对所有权的理解。在具体实现上,dyn TraitA本身会构建一个vitual table进行代理过渡。你可以从这里了解到更细节的信息。https://docs.rs/vptr/latest/vptr/

1. &dyn TraitA 是一个引用,引用指向实现了TraitA特征的具体类型,没有这个具体类型的所有权,相当于借用。 2. Box 是一个智能指针,将实现了TraitA特征的具体类型保存在堆上,并且拥有这个具体类型的所有权。

作者回复: 。初学者可以这样理解。最重要的是对所有权的理解。在具体实现上,dyn TraitA本身会构建一个vitual table进行代理过渡。你可以从这里了解到更细节的信息。https://docs.rs/vptr/latest/vptr/

文章链接

评论可见,请评论后查看内容,谢谢!!!
 您阅读本篇文章共花了: