上一届中有一段代码无法通过编译:
rust
fn returns_summarizable(switch: bool ) -> impl Summary {
if switch {
Post {
// ...
}
} else {
Weibo {
// ...
}
}
}
其中Post 和 Weibo 都实现了Summary 特征, 因此上面的函数 试图通过返回 impl Summary 来返回这两个类型,但是编译器却无情地报错了,原因是 immpl Trait 的返回值类型并不支持多种不同的类型返回, 那如果我们想返回多种类型, 该怎么办 ?
再来考虑一个问题: 现在再做一款游戏,需要将多个对象渲染再屏幕上, 这些对象属于不同的类型, 存储再列表中, 渲染的时候, 需要循环该列表并顺序渲染每个对象, 在Rust 中该怎么实现?
聪明的同学可能已经想到一个办法, 利用枚举:
rust
#[derive(Debug)]
enum UiObject {
Button,
SelectBox,
}
fn main() {
let obects = [ UiObject::Button,UiObject::SelectBox ];
for o in objects {
draw(o)
}
}
fn draw( o: UiObject) {
println!("{:?}",o);
}
Bingo, 这个确实是一个办法,但是问题来了,如果你的对象集合并不能事先明确地知道呢?或者别人想要实现一个UI组件呢?此时枚举中的类型是有些缺少的, 是不是还要修改你的代码增加一个枚举成员 ?
总之, 在编写这个UI库是,我们无法知道所有的UI对象类型, 只知道的是:
-
UI对象的类型不同
-
需要一个统一的类型来处理这些对象,无论是作为函数参数,还是作为列表中的医院
-
需要对每一个对象调用 draw 方法.
在拥有继承的语言中, 可以定义一个名为Component 的类, 该类上有一个draw方法, 其它的类比如Button, Image 和SelectBox ,会从Component 派生并因此继承draw方法,他们各自都可以覆盖 draw方法来定义自己的行为,但是框架会把所有这些类型当作是Component的实例,并在其上调用draw, 不过Rust 并没有继承,我们得另寻出路.
特征对象定义
为了解决上面的所有问题, Rust 引入了一个概念 -- 特征对象.
在介绍特征对象之前, 先来为之前的UI 组件定义一个特征:
rust
pub trait Draw {
fn draw(&self);
}
只要实现了Draw 特征, 就可以调用draw 方法来进行渲染. 假设有一个Button和SelectBox 组件实现了Draw特征:
rust
pub struct Button{
pub width: u32,
pub height: u32,
pub label: String,
}
impl Draw for Button {
fn draw(&self){
// 绘制按钮的代码
}
}
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
//绘制SelectBox的代码
}
}
此时,还需要一个动态数组来存储这些UI对象:
rust
pub struct Screen {
pub components: Vec<?>,
}
注意到上面代码中的? 吗 ? 它的意思是: 我们应该填入什么类型, 可以说就之前学过的内容里,你找不到哪个类型可以填入这里, 但是因为Button 和 SelectBox 都实现了Draw 特征, 那我们是不是可以把Draw 特征的对象作为类型,填入到数组中呢?答案是肯定的.
特征对象指向实现了Draw 特征的类型的实例, 也就是指向了Button 或者 SelectBox 的实例,这种映射关系是存储在一张表中,可以在运行时通过特征对象找到具体的调用的类型方法.
可以通过& 引用或者 Box<T> 智能指针的方式来创建特征对象.
Box<T> 在后面章节会详细讲解, 大家现在把它当成一个引用即可,只不过它包裹的值会被强制分配在堆上.
rust
trait Draw {
fn draw(&self) -> String;
}
impl Draw for u8 {
fn draw(&self) -> String {
format!("u8: {}",*self)
}
}
impl Draw for f64 {
fn draw(&self) -> String {
format!("f64: {}",*self)
}
}
// 若 T 实现了Draw 特征, 则调用该函数时传入的Box <T> 可以被隐式转换成函数参数签名中的 Box<dyn Draw>
fn draw1(x:Box<dyn Draw>) {
// 由于实现了Deref 特征, Box 智能指针会自动解引用 ,为它所包裹的值, 然后调用该值对应的类型上定义的 'draw' 方法,
x.draw();
}
fn draw2(x: &dyn Draw){
x.draw();
}
fn main() {
let x = 1.1f64;
// do_somethine(& x);
let y = 8u8;
//x 和y 的类型 T 都实现了'Draw' 特征, 因为 Box<T> 可以在函数调用时隐式地被转换为特征对象Box<dyn Draw>
// 基于 x 的值创建一个 Box<f64> 类型的只能指针,指针指向的数据被放置在了堆上
draw1(Box::new(x));
// 基于 y 的值创建一个Box<u8> 类型的 智能指针
draw1(Box::new(y));
draw2(&x);
draw2(&y);
}
上面代码, 有几个非常重要的idan:
-
draw1 函数的参数是 Box<dyn Draw> 形式的特征对象, 该特征对象是通过Box::new(x) 的方式创建的,
-
draw2 函数的参数是 &dyn Draw 形式的特征对象, 该特征对象是通过 &x的方式创建的
-
dyn 关键字只用在特征对象的类型声明上, 在创建时无需使用dyn
因此,可以使用特征对象来代表泛型或具体的类型.
继续来完善之前的UI组件代码,首先来实现 Screen:
rust
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
其中存储了一个动态数组,里面元素的类型时Draw 特征对象, Box<dyn Draw> , 任何实现了Draw特征的类型, 都可以存放其中.
在来为Screen 定义run 方法, 用于将列表中的UI嘴贱渲染在屏幕上:
rust
impl Screen {
pub fn run(&self){
for component in self.components.iter() {
component.draw();
}
}
}
至此,我们就完成了之前的目标:在里欸包中存储多种不同类型的实例, 然后将他们使用同一个方法逐一渲染在屏幕上!
再来看看, 如果通过泛型实现, 会如何:
rust
pub struct Screen<T: Draw> {
pub components: Vec<T>,
}
impl<T> Screen<T>
where T: Draw {
pub fn run(&self){
for component in self.components.iter(){
component.draw();
}
}
}
上面的Screen 的列表中, 存储了类型为T的元素, 然后在Screen 中使用特征的约束让 T 实现了Draw 特征,进而可以调用draw方法.
但是这种写法限制了Screen 实例的Vec<T> 中的每个元素必须时 Button 类型或者全是 SelectBox 类型. 如果只需要同质(相同类型) 集合, 更倾向于采用泛型+特征约束这种写法. 因其实现更清晰,且性能更好(特征对象, 需要在运行时从 vtable 动态查找需要调用的方法) .
现在来运行渲染下咱么精心设计的UI 组件列表:
rust
fn main() {
let screen = Screen {
components: vec![
Box::new(SelectBox {
width: 75,
height: 10 ,
options: vec![ String::from("Yes"),
String::from("Maybe"),
String::from("No")
],
}),
Box::new(Button {
width: 50,
height: 10,
label: String::from("OK"),
}),
}
screen.run();
}
上面使用Box::new(T) 的方式来创建了两个Box<dyn Draw> 特征对象, 如果以后还需要增加一个UI组件,那么让该组件实现Draw 特征,则可以很轻松的将其渲染在皮姆上,甚至用户可以引入我们的库作为三方库,然后在自己的库中为自己的类型实现Draw 特征,然后进行渲染.
在动态类型语言中, 有一个很重要的概念: 鸭子类型(duck typing) ,简单来说,就是只关心值长啥样,而不关心它实际是什么, 当一个东西走起来像鸭子,叫起来像鸭子, 那么它就是一只鸭子, 就算它实际上是一个奥特曼, 也不重要, 我们就当它是鸭子.
在上例中,Screen 在run的时候, 我们并不需要知道各个组件的具体类型是什么. 它也不检查组件到底是Button 还是SelectBox的实例, 只要它实现了Draw 特征,就能通过Box::new 包装成 Box<dyn Draw> 特征对象,然后被渲染在屏幕上.
使用特征对象和Rust 类型系统来进行类似鸭子类型操作的优势是, 无需在运行时检查一个值是否实现了特定方法或担心在调用时,因为值没有实现方法而产生错误. 如果值没有实现特征对象所需的特征,那么Rust根本就不会编译这些代码:
rust
fn main() {
let screen = Screen {
components: vec![
Box::new(String::from("Hi")),
],
};
screen.run();
}
因为String 类型没有实现Draw 特征,编译器直接就会报错, 不会让上述代码运行, 如果想要String类型被渲染在屏幕上, 那么只需要为其实现Draw 特征即可, 非常容易.
注意dyn 不能单独作为特征对象的定义,例如下面的代码编译器会报错, 原因特征对象可以是任意实现了某个特征的类型, 编译器在编译期不知道该类型的大小, 不同的类型大小是不同的.
而&dyn 和 Box<dyn>在编译器都是已知大小,所以可以用作特征对象的定义.
rust
fn draw2(x: dyn Draw) {
x.draw();
}
10 | fn draw2(x: dyn Draw) {
| ^ doesn't have a size known at compile-time
|
= help: the trait `Sized` is not implemented for `(dyn Draw + 'static)`
help: function arguments must have a statically known size, borrowed types always have a known size
特征对象的动态分发
回忆一下泛型章节我们提到过的, 泛型是在编译器完成处理的: 编译器会为每一个泛型参数对应的具体类型生成一份代码, 这种方式是静态分发(static dispatch) , 因为是在编译器完成的, 对于运行期性能完全没有任何影响.
与静态分发相对应的是动态分发(dynmic dispathc) ,在这种情况下 , 直到运行时,才能确定需要调用什么方法,之前代码中的关键字dyn 正式在强调这一 "动态" 的特点.
当使用特征对象是,Rust 必须使用动态分发. 编译器无法知晓所有可能用于特征对象代码的类型, 所以它也不知道应该调用哪个类型的哪个方法实现. 为此,Rust 在运行时使用特征对象中的指针来知晓需要调用哪个方法. 动态分发也组织编译器有选择的内敛方法代码, 这会相应的禁用一些优化.
下面这张图很好的解释了静态分发Box<T> 和动态分发Box<dyn Trait> 的区别:

结合上文的内容和这张图可以了解:
-
特征对象大小不固顶:这是因为,对于特征Draw ,类型Button 可以实现特征Draw ,类型 SelectBox 也可以实现特征Draw, 因此特征没有固定大小.
-
几乎总是使用特征对象的引用方式, 如 &dyn Draw , Box<dyn Draw>
-
虽然特征对象没有固定大小,但它的引用类型的大小时固定的, 它由两个指针组成( ptr 和vptr) ,因此占用两个指针大小
-
一个指针 ptr 指向实现了特征 Draw 的具体类型的实例, 也就是当作特征 Draw 来用的类型的实例, 比如类型Button 的实例, 类型 SelectBox的实例
-
另一个指针vptr 指向一个虚表 vtable ,vtable 中保存了类型Button 或类型 SelectBox 的实例对于可以调用的实现于特征 Draw 的方法. 当调用方法时,直接从 vtable 中找到方法并调用, 之所以要使用一个vtable 来保存各实例的方法, 是因为实现了特征Draw 的类型由多种, 这些类型拥有的方法各不相同, 当将这些类型的实例都当作特征Draw来使用时( 此时,他们全都看作是特征Draw 类型的实例) , 有必要区分这些实例各自有哪些方法可调用
简而言之,当类型Button 实现了特征Draw时 ,类型Button 的实例对象 btn 可以当作特征Draw的特征对象类型来使用, btn 中保存了作为特征对象的数据指针 ( 指向类型Button 的实例数据) 和 行为指针 (指向 vtable).
一定要注意,此时的btn 是 Draw 的特征对象的实例, 而不在是具体类型Button 的实例, 而且btn 的vtable 只包含了实现自特征 Draw的那些方法( 比如 draw) ,因此btn 只能调用实现于特征Draw的draw 方法, 而不能调用类型Button 本身实现的方法和类型Button 实现于其它特征的方法, 也就是说. btn 是哪个特征对象的实例,它的vtable 中就包含了该特征的方法
Self 与 self
在Rust 中, 有两个self , 一个指代当前的实例对象, 一个指代特征或者方法类型的别名:
rust
trait Draw {
fn draw(&self) -> Self;
}
#[derive(Clone)]
struct Button;
impl Draw for Button {
fn draw(&self) -> Self {
return self.clone()
}
}
fn main() {
let button = Button;
let newb = button.draw();
}
上述代码中, self 指代的就是当前的实例对象, 也就是button.draw() 中的button实例,Self则指代的是 Button 类型.
当理解了self 与 Self 的区别后, 我们再来看看何为对象安全.
特征对象的限制
不是所有特征都能拥有特征对象, 只有对象安全的特征才行. 当一个特征的所有方法都有如下属性时,它的对象才是安全的;
-
方法的返回类型不能是Self
-
方法没有任何泛型参数
对象安全对于特征对象是必须的, 因为一旦有了特征对象, 就不在需要知道实现该特征的具体类型是什么了. 如果特征方法返回了具体的Self 类型, 但是特征对象忘记了其真正的类型,那这个Self 就非常尴尬,因为没人知道它是谁了. 但是对于泛型类型参数来说,当使用特征时其会放入具体的类型参数: 此具体类型编程了实现该特征的类型的一部分.而当使用特征对象时其具体类型被抹去了,故而无从得知放入泛型参数类型到底是什么.
标准库中的Clone 特征就不符合对象安全的要求:
rust
pub trait Clone {
fn clone(&self) -> Self;
}
因为它的其中一个方法,返回了Self 类型, 因此它时对象不安全的.
String 类型实现了Clone 特征, String 实例上调用clone 方法时会得到一个String 实例. 类似的,当调用Vec<T> 实例的clone方法会得到一个Vec<T> 实例. clone 的签名需要知道什么类型会代替Self ,因为这是它的返回值.
如果违反了对象安全的规则, 编译器会提示你, 例如,如果尝试使用之前的Screen 结构体来存放实现了Clone 特征类型:
rust
pub struct Screen {
pub components: Vec<Box<dyn Clone>>,
}
将会得到如下错误:
error[E0038]: the trait `std::clone::Clone` cannot be made into an object
--> src/lib.rs:2:5
|
2 | pub components: Vec<Box<dyn Clone>>,
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `std::clone::Clone`
cannot be made into an object
|
= note: the trait cannot require that `Self : Sized`
这意味着不能以这种方式使用此特征作为特征对象.