rust入坑小记-05-特征Trait
9 特征Trait
在之前,多次提到Copy、Debug等特征,特征和其他语言的接口很类似,trait 是对未知类型 Self 定义的方法集,如果不同的类型具有相同的行为,那么我们就可以定义一个特征,然后为这些类型实现该特征。定义特征是把一些方法组合在一起,目的是定义一个实现某些目标所必需的行为的集合。
9.1 定义特征
第一步是为类型定义特征,首先创建一个类型,以结构体为例:
1 | struct Dog { |
接下来,我们要定义一个特征:
1 | trait Animal { |
你可以把它理解为go/java的接口,即你只需要声明函数签名,而不需要具体实现。使用trait关键字声明一个特征名为Animal,并定义要实现这个特征所需要的方法集,这里定义了new和introduce两个方法。
9.2 为类型实现特征
声明好了特征之后,需要继续为Dog类型实现这个特征:
1 | impl Animal for Dog { |
实现特征的语法也很简单:impl Animal for Dog,即:为Dog实现Animal特征。注意,实现特征需要具体实现该特征的所有的方法,也就是说,你不能只实现new而不实现introduce,否则就会报错。
最后,我们就可以使用了。下面是完整代码:
1 | struct Dog { |
9.3 孤儿规则 Orphan Rule
定义特征与实现特征有一个限制,那就是只有当特征或者要实现特征的类型位于当前作用域时,才能为该类型实现特征。比如上面的例子,Dog类型定义在当前的作用域中,符合孤儿规则,因此我们可以为Dog实现Display特征;同理,String类型定义在标准库中,但是Animal特征定义在当前作用域,因此可以为String实现Animal特征;但是,不能为String 实现 Display 特征,因为它们都定义于标准库中,和当前程序没有任何关系、这个限制是被称为相干性(coherence) 的程序属性的一部分,或者更具体的说是孤儿规则(orphan rule),其得名于不存在父类型。这条规则确保了其他人编写的代码不会破坏你代码,反之亦然。没有这条规则的话,两个包可以分别对相同类型实现相同的特征,而 rust将无从得知应该使用哪一个实现。
9.4 默认实现
有时为特征中的某些或全部方法提供默认的方法,而不是在每个类型的每个实现中都定义自己的方法是很有用的,因为这样其它类型无需再实现这些默认方法,并且也可以在必要时重载它们:
1 | trait Animal { |
这里我们定义一个默认方法introduce,现在,想要为类型Dog实现Animal特征时,就可以不必实现introduce(当然,你也可以重载它):
1 | struct Dog { |
默认实现允许调用相同特征中的其他方法,哪怕这些方法没有默认实现:
1 | struct Dog { |
我们只需要实现introduce_myself即可,最终会打印出I'm an Animal! A red color dog,age is 3.
9.5 特征作为函数参数
特征可以作为函数参数:
1 | fn notify(item: impl Animal) { |
这里item的类型是impl Animal,即:实现了Animal特征的类型,这意味着你可以传入任何实现了该特征的类型,同时在函数体内,还可以调用该特征的方法,完整代码如下:
1 | struct Dog { |
impl Trait 这种语法适用于简单的例子,实际上是一种较长形式语法的语法糖。我们称为特征约束(trait bound)。
9.6 特征约束
特征约束完整的形式如下,还是上面的例子:
1 | fn notify<T: Animal>(item: T) { |
和函数泛型类似,但是在T后增加了Animal特征的约束。即:notify函数是一个泛型函数,其中参数item的类型为T,且T必须实现Animal特征。
使用这种语法看起来复杂,但是比impl Trait表达性更强。例如,可以获取两个实现Animal特征的参数,如果使用impl Trait:
1 | fn notify(item_a: impl Animal, item_b: impl Animal) {} |
但是,如果要求这两个参数必须是同一个类型,那就只能使用完整语法:
1 | fn notify<T: Animal>(item_a: T, item_b: T) {} |
函数泛型来限制两个参数必须是同一类型,通过特征约束限制这些类型必须实现指定特征。
9.7 多重约束
有时需要对泛型进行多个特征约束,比如既需要该类型实现Debug特征,又需要实现某个自定义特征Animal:
1 | fn notify<T: Animal + Debug>(item_a: T, item_b: T) {} |
使用+连接多个特征,以实现多重约束。使用impl Trait形式的多重特征约束:
1 | fn notify(item: impl Animal + Debug) {} |
9.8 where约束
当多重约束变得很多,多个泛型参数的函数在名称和参数列表之间会有很长的约束信息,使得整个函数签名变得非常长,不易于阅读。为此,rust提供了where,在函数签名后指定多重约束的语法,比如这个多重约束:
1 | fn notify<T: Animal + Debug, U: Debug + Display + Send>(item_a: T, item_b: U) {} |
可以改写为:
1 | fn notify<T, U>(item_a: T, item_b: U) -> () |
这样看起来函数签名就更加紧凑了。
9.9 返回实现了特征的类型
也可以在返回值中使用 impl Trait 语法,来返回实现了某个特征的类型:
1 | fn notify(item: String) -> impl Animal {} |
指定notify函数返回某个实现了Animal的类型,但是不确定其具体的类型。返回一个只是指定了需要实现的特征的类型的能力在闭包和迭代器场景十分的有用。闭包和迭代器创建只有编译器知道的类型,或者是非常非常长的类型。impl Trait 允许你简单的指定函数返回一个 Iterator 而无需写出实际的冗长的类型。
不过这只适用于返回单一类型的情况。如果你的实现是这样的:
1 | // 假设这里Dog和Cat都实现了Animal特征 |
虽然Dog和Cat都实现了Animal特征,但是返回类型不单一,无法通过编译。解决方法留到后面进行讨论,详见特征对象。
9.10 使用特征约束有条件地实现方法
在实现方法时使用特征约束,有条件地约束哪些类型可以拥有方法,哪些类型不可以拥有方法。比如,类型 Pair<T> 总是实现了 new 方法,不过只有那些为 T 类型实现了 PartialOrd 特征(来允许比较)和Display 特征(来启用打印)的 Pair<T> 才会实现 cmp_display 方法:
1 | use std::fmt::Display; |
也可以对任何实现了特定特征的类型有条件地实现特征。对任何满足特定特征约束的类型实现特征被称为通用实现(blanket implementations) ,它们被广泛的用于rust标准库中。例如,标准库为任何实现了Display 特征的类型实现了 ToString 特征。这个impl块看起来像这样:
1 | impl<T: Display> ToString for T { |
再举个通俗的例子——学校要给学生颁奖,但是,想要获得颁奖资格,学生必须获得四级证书:对任何实现了特定特征的类型(有四级证书的学生)实现特征(有资格获奖)。
特征和特征约束让我们使用泛型类型参数来减少重复,并仍然能够向编译器明确指定泛型类型需要拥有哪些行为。因为我们向编译器提供了特征约束信息,它就可以检查代码中所用到的具体类型是否提供了正确的行为。
在动态类型语言中,如果我们尝试调用一个类型并没有实现的方法,会在运行时出现错误。rust将这些错误移动到了编译时,甚至在代码能够运行之前就强迫我们修复错误。另外,我们也无需编写运行时检查行为的代码,因为在编译时就已经检查过了,这样相比其他那些不愿放弃泛型灵活性的语言有更好的性能。
9.11 derive
还记得打印结构体时使用的#[derive(Debug)]吗,这种是一种特征派生语法,被 derive 标记的对象会自动实现对应的默认特征代码,继承相应的功能。对于Debug 特征,rust有写好的自动实现的默认代码,当你给一个结构体标记后,就可以使用 println!("{:?}", s) 的形式打印该结构体的对象。再如 Copy 特征,它也有一套自动实现的默认代码,当标记到一个类型上时,可以让这个类型自动实现 Copy 特征,进而可以调用 copy 方法,进行自我复制。
总之,derive 派生出来的是rust默认给我们提供的特征,在开发过程中极大的简化了自己手动实现相应特征的需求,当然,如果你有特殊的需求,还可以自己手动重载该实现。
9.12 特征泛型
特征中也可以使用泛型,它的定义如下:
1 | trait A<U> {} |
举个具体的例子:
1 | trait A<T> { |
这里定义了一个特征泛型A,其中包含一个名为foo的方法,该方法接受一个类型参数T,并返回一个类型为T的值。
接下来,我们定义一个结构体B,并为它实现了特征A,并提供了一个foo方法,该方法返回传递给它的参数值。然后在main函数中,我们首先创建了一个B对象b,然后使用foo方法将x值、y值分别传递给b.foo并打印。
可以看出,对于不同类型的x:i32和y:String,foo方法都可以接受它们,这使得特征可以不受类型的限制,减少编码冗余。
在后面的章节默认泛型类型参数和运算符重载中,我们还会使用到特征泛型。
9.13 Supertraits
Supertraits是为实现特定特征而需要为类型实现的特征,这可能有些拗口,但是如果将其比喻为面向对象中类的继承,就稍微好理解了一点(当然并不严谨)。
Supertraits是通过特征的 Self类型上的特征约束来声明的,并且通过这种声明特征约束的方式来传递这种Supertraits关系。一个 trait 不能是它自己的Supertrait。
下面是一个声明 Shape 是 Circle 的Supertrait的例子。
1 | trait Shape { fn area(&self) -> f64; } |
下面是同改成使用where约束的等效实现:
1 | trait Shape { fn area(&self) -> f64; } |