特征(trait)是rust中的概念,类似于其他语言中的接口(interface)。在之前的代码中,我们也多次见过特征的使用,例如 #[derive(Debug)]
,它在我们定义的类型(struct)上自动派生 Debug 特征,接着可以使用 println!("{:?}", x)
打印这个类型。
特征定义了一个可以被共享的行为,只要实现了特征,你就能使用该行为。
如果不同的类型具有相同的行为,那么我们就可以定义一个特征,然后为这些类型实现该特征。定义特征是把一些方法组合在一起,目的是定义一个实现某些目标所必需的行为的集合。
例如,我们现在有圆形和长方形两个结构体,它们都可以拥有周长,面积。因此我们可以定义被共享的行为,只要实现了特征就可以使用。
pub trait Figure { // 为几何图形定义名为Figure的特征
fn girth(&self) -> u64; // 计算周长
fn area(&self) -> u64; // 计算面积
}
这里使用 trait 关键字来声明一个特征,Figure 是特征名。在大括号中定义了该特征的所有方法,在这个例子中有两个方法,分别是fn girth(&self) -> u64;
和fn area(&self) -> u64;
,特征只定义行为看起来是什么样的,而不定义行为具体是怎么样的。因此,我们只定义特征方法的签名,而不进行实现,此时方法签名结尾是 ;,而不是一个 {}。
接下来,每一个实现这个特征的类型都需要具体实现该特征的相应方法,编译器也会确保任何实现 Figure 特征的类型都拥有与fn girth(&self) -> u64;
和fn area(&self) -> u64;
签名的定义完全一致的方法。
熟悉C++的同学看到这里,会觉得trait和C++的纯虚函数非常类似;而熟悉go语言的同学看到这里会觉得和go语言的interface非常类似。
上面声明了特征,但是它只包含了一个函数声明,而没有实现。接下来要为具体的类型实现特征。
use std::f64::consts::PI;
trait Figure { // 为几何图形定义名为Figure的特征
fn girth(&self) -> f64; // 计算周长
fn area(&self) -> f64; // 计算面积
}
struct Rectangle{
x:f64,
y:f64,
}
struct Circle{
r:f64,
}
impl Rectangle {
// 为Rectangle实现构造方法
fn new(x:f64, y:f64) -> Self{
Rectangle { x, y }
}
}
impl Circle {
// 为Circle实现构造方法
fn new(r:f64) -> Self{
Circle { r }
}
}
impl Figure for Rectangle {
fn area(&self) -> f64 {
self.x * self.y
}
fn girth(&self) -> f64 {
2.0 * (self.x + self.y)
}
}
impl Figure for Circle{
fn area(&self) -> f64 {
2.0 * PI * self.r
}
fn girth(&self) -> f64 {
PI * self.r.powi(2i32)
}
}
fn main() {
let rec = Rectangle::new(1.0, 2.0);
let cir = Circle::new(3.0);
println!("长方形的周长是{},面积是{}",rec.girth(), rec.area());
println!("圆形的周长是{},面积是{}", cir.girth(), cir.area());
}
impl Figure for Circle
,意为“为 Circle 类型实现 Figure 特征”,然后在 impl 中实现该特征的具体方法。这种将接口分离出来的做法有别于传统面向对象的语言(例如C++)。这种做法是组合优于继承的一种体现。
这段代码执行结果如下所示:
长方形的周长是6,面积是2
圆形的周长是28.274333882308138,面积是18.84955592153876
如果想让别人使用我们的特征,那么需要将特征和对应的类型定义为pub,例如:
pub trait Figure { // 为几何图形定义名为Figure的特征
fn girth(&self) -> f64; // 计算周长
fn area(&self) -> f64; // 计算面积
}
pub struct Rectangle{
pub x:f64,
pub y:f64,
}
pub struct Circle{
pub r:f64,
}
关于特征实现与定义的位置,有一条非常重要的原则:如果你想要为类型 A 实现特征 T,那么 A 或者 T 至少有一个是在当前作用域中定义的!
但是你无法在当前作用域中,为 String 类型实现 Display 特征,因为它们俩都定义在标准库中,其定义所在的位置都不在当前作用域,跟你半毛钱关系都没有,看看就行了。该规则被称为孤儿规则,可以确保其它人编写的代码不会破坏你的代码,也确保了你不会莫名其妙就破坏了风马牛不相及的代码。
你可以在特征中定义具有默认实现的方法,这样其它类型无需再实现该方法,或者也可以选择重载该方法。这和C++的虚函数类似,允许方法有实现,而不仅仅是声明。例如:
pub struct Dog;
pub struct Cat;
pub trait Animal {
fn run(&self) {
println!("跑跑跑");
}
}
impl Animal for Dog { // 为Dog实现特征Animal
}
impl Animal for Cat { // 为Cat实现特征Animal
fn run(&self) {
println!("猫在跑");
}
}
fn main() {
let cat = Cat;
let dog = Dog;
dog.run();
cat.run();
}
程序执行结果:
跑跑跑
猫在跑
在特征中定义具有默认实现的方法,这样其它类型无需再实现该方法,或者也可以选择重载该方法。Dog类型使用默认的run方法,而Cat类型选择重载了run方法。
默认实现允许调用相同特征中的其他方法,哪怕这些方法没有默认实现。如此,特征可以提供很多有用的功能而只需要实现指定的一小部分内容。例如:
pub struct Dog;
pub struct Cat;
pub trait Animal {
fn run(&self) {
println!("跑跑跑");
}
fn eat(&self);
fn sleep(&self);
fn every_daya(&self) {
self.eat();
self.sleep();
}
}
impl Animal for Dog { // 为Dog实现特征Animal
fn eat(&self) {
println!("狗需吃狗粮");
}
fn sleep(&self) {
println!("狗在睡觉");
}
}
impl Animal for Cat { // 为Cat实现特征Animal
fn run(&self) {
println!("猫在跑");
}
fn eat(&self) {
println!("猫咪需要吃猫粮");
}
fn sleep(&self) {
println!("猫咪白天在睡觉");
}
}
fn main() {
let cat = Cat;
let dog = Dog;
dog.run();
cat.run();
dog.every_daya();
cat.every_daya();
}
我们给Animal特征加上了sleep,eat以及every_day这三个方法。其中every_day方法,我们做了实现。而sleep方法和eat方法没有在特征中做默认实现,但是这不影响我们在every_day方法中调用它们。我们分别为Cat和Dog类型实现了eat方法和sleep方法。
这和C++的虚基类非常类似,在C++中我们需要做的是在派生类中实现纯虚函数或者重载虚函数,这里用的手段是继承。而在rust里可以由特征来实现。
特征如果仅仅是用来实现方法,那真的有些大材小用,现在我们来讲下,真正可以让特征大放光彩的地方。
现在,先定义一个函数,使用特征作为函数参数:
pub fn notify(item: &impl Animal) {
println!("{}", item.run());
}
impl Animal,它的意思是 实现了Animal特征 的 item 参数。你可以使用任何实现了 Summary 特征的类型作为该函数的参数,同时在函数体内,还可以调用该特征的方法。例如,可以传递 Cat 或 Dog 的实例来作为参数,而其它类型,如 String 或者 i32 的类型则不能用做该函数的参数,因为它们没有实现 Animal 特征。
虽然 impl Trait 这种语法非常好理解,但是实际上它只是一个语法糖,在泛型中如下所示:
pub fn ast<T: Summary>(item: &T) {
println!("{}", item.a());
}
T: Summary 被称为特征约束,它能够做的对类型进行约束。例如上面的notify函数的参数只要实现了Animal特征即可,那么意味着我们可以传递Dog或者Cat的实例对象。如果我们有下面这样的函数。
fn func(a:&impl Animal, b:&impl Animal){
// todo
}
想要函数的两个参数是同一种类型,而不仅仅是实现了Animal特征即可。此时就需要使用特征约束。
fn func<T: Animal>(a:&T, b:&T){
// todo
}
像这样的方式就说明了泛型类型T必须实现Animal特征,且a和b必须是同一种类型。
除了单个约束条件,我们还可以指定多个约束条件,例如除了让参数实现 Animal 特征外,还可以让参数实现 Display 特征以控制它的格式化输出:
pub fn func(item: &(impl Animal + Display)) {}
当然了,在泛型中使用如下的形式:
pub fn func<T: Animal + Display>(item: &T) {}
当特征约束变得很多时,函数的签名将变得很复杂:
fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {}
通过 where约束,可以将其变成下面的形式。
fn some_function<T, U>(t: &T, u: &U) -> i32
where T: Display + Clone,
U: Clone + Debug
{}
特征约束,可以让我们在指定类型 + 指定特征的条件下去实现方法,例如:
#![allow(unused)]
fn main() {
use std::fmt::Display;
struct Pair<T> {
x: T,
y: T,
}
impl<T> Pair<T> {
fn new(x: T, y: T) -> Self {
Self {
x,
y,
}
}
}
impl<T: Display + PartialOrd> Pair<T> {
fn cmp_display(&self) {
if self.x >= self.y {
println!("The largest member is x = {}", self.x);
} else {
println!("The largest member is y = {}", self.y);
}
}
}
}
cmp_display 方法,并不是所有的 Pair 结构体对象都可以拥有,只有 T 同时实现了 Display + PartialOrd 的 Pair 才可以拥有此方法。 该函数可读性会更好,因为泛型参数、参数、返回值都在一起,可以快速的阅读,同时每个泛型参数的特征也在新的代码行中通过特征约束进行了约束。
也可以有条件地实现特征, 例如,标准库为任何实现了 Display 特征的类型实现了 ToString 特征:
impl<T: Display> ToString for T {
// --snip--
}
fn return_run() -> impl Animal {
Cat
}
Cat实现了Animal特征,因此可以用Cat对象的实例作为返回值。要注意的是,虽然我们知道这里是一个 Cat 类型,但是对于 return_run 的调用者而言,他只知道返回了一个实现了 Animal 特征的对象,但是并不知道返回了一个 Cat 类型。
这种 impl Trait 形式的返回值,在一种场景下非常非常有用,那就是返回的真实类型非常复杂,你不知道该怎么声明时(毕竟 Rust 要求你必须标出所有的类型),此时就可以用 impl Trait 的方式简单返回。
形如 #[derive(Debug)]
的代码已经出现了很多次,这种是一种特征派生语法,被 derive 标记的对象会自动实现对应的默认特征代码,继承相应的功能。
例如 Debug 特征,它有一套自动实现的默认代码,当你给一个结构体标记后,就可以使用 println!(“{:?}”, s) 的形式打印该结构体的对象。
再如 Copy 特征,它也有一套自动实现的默认代码,当标记到一个类型上时,可以让这个类型自动实现 Copy 特征,进而可以调用 copy 方法,进行自我复制。
总之,derive 派生出来的是 Rust 默认给我们提供的特征,在开发过程中极大的简化了自己手动实现相应特征的需求,当然,如果你有特殊的需求,还可以自己手动重载该实现。
如果你要使用一个特征的方法,那么你需要将该特征引入当前的作用域中。后续在包和模块中,我们来演示该部分。
关联类型是在特征定义的语句块中,申明一个自定义类型,这样就可以在特征的方法签名中使用该类型,例如:
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
例如,这是标准库中的迭代器特征 Iterator,它有一个 Item 关联类型,用于替代遍历的值的类型。
同时,next 方法也返回了一个 Item 类型,不过使用 Option 枚举进行了包裹,假如迭代器中的值是 i32 类型,那么调用 next 方法就将获取一个 Option<i32>
的值。
当然也可以使用泛型表示,例如:
pub trait Iterator<Item> {
fn next(&mut self) -> Option<Item>;
}
当你使用了泛型后,你需要在所有地方都写 Iterator<Item>
,而使用了关联类型,你只需要写 Iterator,当类型定义复杂时,这种写法可以极大的增加可读性,例如:
pub trait CacheableItem: Clone + Default + fmt::Debug + Decodable + Encodable {
type Address: AsRef<[u8]> + Clone + fmt::Debug + Eq + Hash;
fn is_null(&self) -> bool;
}
Address 的写法自然远比 AsRef<[u8]> + Clone + fmt::Debug + Eq + Hash
要简单的多,而且含义清晰。
当使用泛型类型参数时,可以为其指定一个默认的具体类型,例如标准库中的 std::ops::Add 特征:
trait Add<RHS=Self> {
type Output;
fn add(self, rhs: RHS) -> Self::Output;
}
它有一个泛型参数 RHS,但是与我们以往的用法不同,这里它给 RHS 一个默认值,也就是当用户不指定 RHS 时,默认使用两个同样类型的值进行相加,然后返回一个关联类型 Output。实际的例子如下:
use std::ops::Add;
#[derive(Debug, PartialEq)]
struct Point {
x: i32,
y: i32,
}
impl Add for Point {
type Output = Point;
fn add(self, other: Point) -> Point {
Point {
x: self.x + other.x,
y: self.y + other.y,
}
}
}
fn main() {
assert_eq!(Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
Point { x: 3, y: 3 });
}
上面的代码主要干了一件事,就是为 Point 结构体提供 + 的能力,这就是运算符重载,不过 Rust 并不支持创建自定义运算符,你也无法为所有运算符进行重载,目前来说,只有定义在 std::ops 中的运算符才能进行重载。
例子中并没有为 Point 实现 Add<RHS>
特征,而是实现了 Add 特征(没有默认泛型类型参数),这意味着我们使用了 RHS 的默认类型,也就是 Self。
默认类型参数主要用于两个方面:
不同特征拥有同名的方法是很正常的事情,你没有任何办法阻止这一点;甚至除了特征上的同名方法外,在你的类型上,也有同名方法。
#![allow(unused)]
fn main() {
trait Pilot {
fn fly(&self);
}
trait Wizard {
fn fly(&self);
}
struct Human;
impl Pilot for Human {
fn fly(&self) {
println!("This is your captain speaking.");
}
}
impl Wizard for Human {
fn fly(&self) {
println!("Up!");
}
}
impl Human {
fn fly(&self) {
println!("*waving arms furiously*");
}
}
}
特征 Pilot 和 Wizard 有 fly 方法,就连实现那两个特征的 Human 单元结构体,也拥有一个同名方法 fly 。
当调用 Human 实例的 fly 时,编译器默认调用该类型中定义的方法:
fn main() {
let person = Human;
person.fly();
}
这段代码会打印 waving arms furiously,说明直接调用了类型上定义的方法。
为了能够调用两个特征的方法,需要使用显式调用的语法:
fn main() {
let person = Human;
Pilot::fly(&person); // 调用Pilot特征上的方法
Wizard::fly(&person); // 调用Wizard特征上的方法
person.fly(); // 调用Human类型自身的方法
}
这是在存在self参数的情况下,如果不存在self参数的关联函数。那么则无法使用上述的方式进行调用。则需要使用完全限定语法。例如:
trait Animal {
fn baby_name() -> String;
}
struct Dog;
impl Dog {
fn baby_name() -> String {
String::from("Spot")
}
}
impl Animal for Dog {
fn baby_name() -> String {
String::from("puppy")
}
}
fn main() {
println!("A baby dog is called a {}", Dog::baby_name());
}
此时调用Dog的特征Animal中的方法baby_name的方式如下所示:
fn main() {
println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
}
通过 as 关键字,我们向 Rust 编译器提供了类型注解,也就是 Animal 就是 Dog,而不是其他动物,因此最终会调用 impl Animal for Dog 中的方法。
完全限定语法可以用于任何函数或方法调用,那么我们为何很少用到这个语法?原因是 Rust 编译器能根据上下文自动推导出调用的路径,因此大多数时候,我们都无需使用完全限定语法。只有当存在多个同名函数或方法,且 Rust 无法区分出你想调用的目标函数时,该用法才能真正有用武之地。
有时,我们会需要让某个特征 A 能使用另一个特征 B 的功能(另一种形式的特征约束),这种情况下,不仅仅要为类型实现特征 A,还要为类型实现特征 B 才行,这就是 supertrait。例如有一个特征 OutlinePrint,它有一个方法,能够对当前的实现类型进行格式化输出:
#![allow(unused)]
fn main() {
use std::fmt::Display;
trait OutlinePrint: Display {
fn outline_print(&self) {
let output = self.to_string();
let len = output.len();
println!("{}", "*".repeat(len + 4));
println!("*{}*", " ".repeat(len + 2));
println!("* {} *", output);
println!("*{}*", " ".repeat(len + 2));
println!("{}", "*".repeat(len + 4));
}
}
}
假如没有这个特征约束,那么 self.to_string 无法调用( to_string 方法会为实现 Display 特征的类型自动实现)。
使用newtype 模式来绕过孤儿规则,简而言之:就是为一个元组结构体创建新类型。该元组结构体封装有一个字段,该字段就是希望实现特征的具体类型。newtype 不仅仅能实现以上的功能,而且它在运行时没有任何性能损耗,因为在编译期,该类型会被自动忽略。下面来看一个例子,我们有一个动态数组类型: Vec<T>
,它定义在标准库中,还有一个特征 Display,它也定义在标准库中,如果没有 newtype,我们是无法为 Vec<T>
实现 Display 的。
use std::fmt;
struct Wrapper(Vec<String>);
impl fmt::Display for Wrapper {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "[{}]", self.0.join(", "))
}
}
fn main() {
let w = Wrapper(vec![String::from("hello"), String::from("world")]);
println!("w = {}", w);
}
其中,struct Wrapper(Vec<String>)
就是一个元组结构体,它定义了一个新类型 Wrapper。注意到我们怎么访问里面的数组吗?self.0.join(", "),是的,很啰嗦,因为需要先从 Wrapper 中取出数组: self.0,然后才能执行 join 方法。类似的,任何数组上的方法,你都无法直接调用,需要先用 self.0 取出数组,然后再进行调用。
当然,解决办法还是有的,要不怎么说 Rust 是极其强大灵活的编程语言!Rust 提供了一个特征叫 Deref,实现该特征后,可以自动做一层类似类型转换的操作,可以将 Wrapper 变成 Vec<String>
来使用。这样就会像直接使用数组那样去使用 Wrapper,而无需为每一个操作都添加上 self.0。同时,如果不想 Wrapper 暴露底层数组的所有方法,我们还可以为 Wrapper 去重载这些方法,实现隐藏的目的。