Rust-特征
如果不同的类型具有相同的行为,那么我们就可以定义一个特征,然后为这些类型实现该特征。定义特征是把一些方法组合在一起,目的是定义一个实现某些目标所必需的行为的集合。
例如,我们现在有文章 Post 和微博 Weibo 两种内容载体,而我们想对相应的内容进行总结,也就是无论是文章内容,还是微博内容,都可以在某个时间点进行总结,那么总结这个行为就是共享的,因此可以用特征来定义:
1 | pub trait Summary { |
特征只定义行为看起来是什么样的,而不定义行为具体是怎么样的。因此我们需要为实现特征的类型,定义行为具体是怎么样的。
1 | pub struct Post { |
孤儿规则
上面我们将 Summary 定义成了 pub 公开的。这样,如果他人想要使用我们的 Summary 特征,则可以引入到他们的包中,然后再进行实现。
关于特征实现与定义的位置,有一条非常重要的原则:如果你想要为类型 A 实现特征 T,那么 A 或者 T 至少有一个是在当前作用域中定义的!
例如我们可以为上面的 Post 类型实现标准库中的 Display 特征,这是因为 Post 类型定义在当前的作用域中。同时,我们也可以在当前包中为 String 类型实现 Summary 特征,因为 Summary 定义在当前作用域中。但是你无法在当前作用域中,为 String 类型实现 Display 特征,因为它们俩都定义在标准库中,其定义所在的位置都不在当前作用域。
该规则被称为孤儿规则,可以确保其它人编写的代码不会破坏你的代码,也确保了你不会破坏其他的代码。
在外部类型上实现外部特征(newtype)
这里提供一个办法来绕过孤儿规则,那就是使用newtype 模式,简而言之:就是为一个元组结构体创建新类型。该元组结构体封装有一个字段,该字段就是希望实现特征的具体类型。该封装类型是本地的,因此我们可以为此类型实现外部的特征。
例如我们有一个动态数组类型: Vec<T>
,它定义在标准库中,还有一个特征 Display,它也定义在标准库中,如果没有 newtype,我们是无法为 Vec<T>
实现 Display 的,此时可以使用 newType。
1 | use std::fmt; |
默认实现
可以在特征中定义具有默认实现的方法,这样其它类型无需再实现该方法,或者也可以选择重载该方法:
1 | pub trait Summary { |
同名方法
不同特征拥有同名的方法是很正常的事情,你没有任何办法阻止这一点;甚至除了特征上的同名方法外,在你的类型上,也有同名方法:
1 | trait Pilot { |
优先调用类型上的方法
当调用 Human 实例的 fly 时,编译器默认调用该类型中定义的方法:
1 | fn main() { |
调用特征上的方法
1 | 为了能够调用两个特征的方法,需要使用显式调用的语法: |
完全限定语法
完全限定语法是调用函数最为明确的方式:
1 | trait Animal { |
在尖括号中,通过 as 关键字,我们向 Rust 编译器提供了类型注解,也就是 Animal 就是 Dog,而不是其他动物,因此最终会调用 impl Animal for Dog
中的方法。
完全限定语法定义为:
1 | <Type as Trait>::function(receiver_if_method, next_arg, ...); |
上面定义中,第一个参数是方法接收器 receiver(三种 self),只有方法才拥有,例如关联函数就没有 receiver。
关联类型
关联类型是在特征定义的语句块中,申明一个自定义类型,这样就可以在特征的方法签名中使用该类型:
1 | pub trait Iterator { |
通过关联类型,代码的可读性会比使用泛型更高。
函数参数的特征约束
定义一个函数,使用特征作为函数参数:
1 | pub fn notify(item: &impl Summary) { |
impl Summary
的意思是 实现了Summary特征 的 item 参数。这其实是个语法糖,依赖了特征约束(trait bound)语法。实际代码如下:
1 | pub fn notify<T: Summary>(item: &T) { |
对于复杂的场景,特征约束可以让我们拥有更大的灵活性和语法表现能力,例如一个函数接受两个 impl Summary 的参数:
1 | pub fn notify(item1: &impl Summary, item2: &impl Summary) {} |
如果函数两个参数是不同的类型,那么上面的方法很好,只要这两个类型都实现了 Summary 特征即可。但是如果我们想要强制函数的两个参数是同一类型呢?上面的语法就无法做到这种限制,此时我们只能使特征约束来实现:
1 | pub fn notify<T: Summary>(item1: &T, item2: &T) {} |
泛型类型 T 说明了 item1 和 item2 必须拥有同样的类型,同时 T: Summary
说明了 T 必须实现 Summary 特征。
多重约束
除了单个约束条件,我们还可以指定多个约束条件,例如除了让参数实现 Summary 特征外,还可以让参数实现 Display 特征以控制它的格式化输出:
1 | pub fn notify(item: &(impl Summary + Display)) {} |
通过这两个特征,就可以使用 item.summarize
方法,以及通过 println!("{}", item)
来格式化输出 item。
Where 约束
当特征约束变得很多时,函数的签名将变得很复杂:
1 | fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {} |
通过 where能对其做一些形式上的改进:
1 | fn some_function<T, U>(t: &T, u: &U) -> i32 |
使用特征约束有条件地实现方法或特征
特征约束,可以让我们在指定类型 + 指定特征的条件下去实现方法,例如:
1 | use std::fmt::Display; |
cmp_display 方法,并不是所有的 Pair<T>
结构体对象都可以拥有,只有 T 同时实现了 Display + PartialOrd
的 Pair<T>
才可以拥有此方法。 该函数可读性会更好,因为泛型参数、参数、返回值都在一起,可以快速的阅读,同时每个泛型参数的特征也在新的代码行中通过特征约束进行了约束。
也可以有条件地实现特征,例如,标准库为任何实现了 Display 特征的类型实现了 ToString 特征:
1 | impl<T: Display> ToString for T { |
我们可以对任何实现了 Display 特征的类型调用由 ToString 定义的 to_string 方法。
函数返回的特征约束
可以通过 impl Trait 来说明一个函数返回了一个类型,该类型实现了某个特征:
1 | fn returns_summarizable() -> impl Summary { |
虽然我们知道这里是一个 Post 类型,但是对于 returns_summarizable 的调用者而言,他只知道返回了一个实现了 Summary 特征的对象,但是并不知道返回了一个 Post 类型。
但是这种返回值方式有一个很大的限制, 只能有一个具体的类型 [1]:
1 | fn returns_summarizable(switch: bool) -> impl Summary { |
特征对象
在上一节中有一段代码无法通过编译:
1 | fn returns_summarizable(switch: bool) -> impl Summary { |
其中 Post 和 Weibo 都实现了 Summary 特征,因此上面的函数试图通过返回 impl Summary 来返回这两个类型,但是编译器却无情地报错了,原因是 impl Trait 的返回值类型并不支持多种不同的类型返回,那如果我们想返回多种类型,该怎么办?
为了解决上面的所有问题,Rust 引入了一个概念 —— 特征对象。
1 | /// 只要组件实现了 Draw 特征,就可以调用 draw 方法来进行渲染。 |
特征对象指向实现了 Draw 特征的类型的实例,也就是指向了 Button 或者 SelectBox 的实例,这种映射关系是存储在一张表中,可以在运行时通过特征对象找到具体调用的类型方法。
可以通过 &dyn Draw
借用[2]或者 Box<dyn Draw>
智能指针的方式来创建特征对象。
注意 dyn 不能单独作为特征对象的定义,例如下面的代码编译器会报错,原因是特征对象可以是任意实现了某个特征的类型,编译器在编译期不知道该类型的大小,不同的类型大小是不同的。而
&dyn
和Box<dyn>
在编译期都是已知大小,所以可以用作特征对象的定义。
特征对象的动态分发
泛型是在编译期完成处理的:编译器会为每一个泛型参数对应的具体类型生成一份代码,这种方式是静态分发(static dispatch),因为是在编译期完成的,对于运行期性能完全没有任何影响。
与静态分发相对应的是动态分发(dynamic dispatch),在这种情况下,直到运行时,才能确定需要调用什么方法。之前代码中的关键字 dyn 正是在强调这一“动态”的特点。
当使用特征对象时,Rust 必须使用动态分发。编译器无法知晓所有可能用于特征对象代码的类型,所以它也不知道应该调用哪个类型的哪个方法实现。为此,Rust 在运行时使用特征对象中的指针来知晓需要调用哪个方法。动态分发也阻止编译器有选择的内联方法代码,这会相应的禁用一些优化。

- 特征对象大小不固定:这是因为,对于特征 Draw,类型 Button 可以实现特征 Draw,类型 SelectBox 也可以实现特征 Draw,因此特征没有固定大小
- 几乎总是使用特征对象的引用方式,如 &dyn Draw、Box
- 虽然特征对象没有固定大小,但它的引用类型的大小是固定的,它由两个指针组成(ptr 和 vptr),因此占用两个指针大小
- 一个指针 ptr 指向实现了特征 Draw 的具体类型的实例,也就是当作特征 Draw 来用的类型的实例,比如类型 Button 的实例、类型 SelectBox 的实例
- 另一个指针 vptr 指向一个虚表 vtable,vtable 中保存了类型 Button 或类型 SelectBox 的实例对于可以调用的实现于特征 Draw 的方法。当调用方法时,直接从 vtable 中找到方法并调用。
当类型 Button 实现了特征 Draw 时,类型 Button 的实例对象 btn 可以当作特征 Draw 的特征对象类型来使用,btn 中保存了作为特征对象的数据指针(指向类型 Button 的实例数据)和行为指针(指向 vtable)。
一定要注意,此时的 btn 是 Draw 的特征对象的实例,而不再是具体类型 Button 的实例,而且 btn 的 vtable 只包含了实现自特征 Draw 的那些方法(比如 draw),因此 btn 只能调用实现于特征 Draw 的 draw 方法,而不能调用类型 Button 本身实现的方法和类型 Button 实现于其他特征的方法。
Self 与 self
在 Rust 中,有两个self,一个指代当前的实例对象,一个指代特征或者方法类型的别名:
1 | trait Draw { |
上述代码中,self指代的就是当前的实例对象,也就是 button.draw() 中的 button 实例,Self 则指代的是 Button 类型。
特征对象的限制
不是所有特征都能拥有特征对象,只有对象安全的特征才行。当一个特征的所有方法都有如下属性时,它的对象才是安全的:
- 方法的返回类型不能是 Self
- 方法没有任何泛型参数
标准库中的 Clone 特征就不符合对象安全的要求:
1 | pub trait Clone { |
因为它的clone方法,返回了 Self 类型,因此它是对象不安全的。
Async Trait
如果在traits中编写async fn,例如async fn foo(&self)
,trait以及impl块中语法糖会被解糖为:
1 | trait Trait { |
使用异步函数的trait在dyn情况下并不安全,因为我们并不知道Future具体是什么类型;使用dyn时必须列出所有的关联类型的值。也就是说,如果要使用dyn,必须确定 Future 的实际类型:
1 | // XXX是impl块定义的future类型 |
这使dyn trait限制于某一个特定的impl块,而这与dyn trait的设计意图冲突:在使用dyn时,用户并不知道实际上的类型是什么,只知道类型实现了目标trait。出于这个原因,一个使用#[async_trait]
的改进方式如下:
1 |
|
这样子做可以通过编译,缺点在于,哪怕不使用dyn trait,也会在堆上为Box分配空间,以及用户必须提早声明Box<...>
是否实现了Sendtrait。这会带来不必要的麻烦,并且与Rust的设计意图冲突。