rust 快速入门——11 Trait

116 阅读34分钟

[!|center] 普若哥们儿

github.com/wu-hongbing…

gitee.com/wuhongbing/…

特性

trait 翻译为特性,类似于其他语言中 接口(interfaces)trait 定义了一组特定的功能,可以通过 trait 定义多个类型的共同行为。可以使用 特性约束 (trait bounds) 指定泛型拥有特定行为的类型。

interface 的语法地位类似于class(出现 class 的语法结构通常也可以出现 interface),同样trait的语法地位类似于类型

Rust 默认为基本类型实现了很多 trait,约束和扩展了数据的行为,这与很多高级语言的思想是一致的,比如 Javascript 认为“一切皆对象”,任何类型均继承了 Object 基类,均附加了方法。

与很多高级语言不同,rust 语言规范不限于关键字和运算符的语法逻辑,还包括一些特定的 Trait,比如 Sized、Drop、Copy,……等,这些 Trait 是内嵌于语言中的。在特定的情形下,编译器会自动生成调用变量类型所实现的特定的 Trait 的代码,编译器也会通过查看变量类型是否支持特定的 Trait 而判定是否存在语法错误。

为某个数据类型实现特定的 Trait 使得该数据类型的对象具有了可供调用的方法,实现了“数据+方法的封装”,这是 Rust 实现面向对象的一种方法,Rust 还有直接向类型添加方法的语法,在下一章我们将全面介绍 Rust 面向对象的内容。

定义 trait

一个类型的行为由其可供调用的方法构成,如果可以对不同类型调用相同的方法的话,这些类型就可以共享相同的行为了。trait 定义是一种将方法签名组合起来的方法,目的是定义一个实现某些目的所必需的行为的集合。

trait Draw {  //相当于java中的interface,或者C++中的抽象类
    fn draw(&self); //相当于接口说明
}

这里使用 trait 关键字来声明一个 trait,后面是 trait 的名字,在这个例子中是 Draw。在大括号中声明描述实现这个 trait 的类型所需要的行为的方法签名,在这个例子中是 fn draw(&self)&self 是简写,完整形式为:

trait Draw {  //相当于java中的interface,或者C++中的抽象类
    fn draw(self:&Self); //相当于接口说明
}

Self 是关键字,表示 trait 所关联的数据类型的别名,&Self 则是对 trait 所关联的数据类型的引用类型。当某个类型实现了 Draw trait,self 变量就是该类型的引用类型的实例,类似于 C++和 java 中的 this 指针。

方法签名也可以没有 &self 参数,这样该方法只能通过实现 trait 的类型进行调用,而不能通过该类型的实例进行调用:

trait Draw {  //相当于java中的interface,或者C++中的抽象类
    fn draw(); //只能通过类型名调用
}

在方法签名后跟分号,而不是在大括号中提供其实现,每一个实现这个 trait 的类型都需要提供其自定义行为的方法体。

trait 体中可以有多个方法,一行一个方法签名且都以分号结尾。

方法签名后也可以跟大括号,为 trait 中的方法提供默认实现

为类型实现 trait

为类型实现 trait 的语法为 imp <Trait> for <Type> ,为类型实现了 trait 后,就可以通过类型的实例调用 trait 中的方法,此时编译器隐含地传递对象的引用作为方法中 self 参数,不需要显式传递

还可以通过 trait 名称调用 trait 中的方法,此时则需要明确传递类型实例的引用作为 self 参数。比如:

trait Draw {
    //相当于java中的interface,或者C++中的抽象类
    fn draw(self: &Self); //相当于接口说明
}

struct Rectangle {
    width: u32,
    height: u32,
}

impl Draw for Rectangle {
    //实现接口
    fn draw(&self) {
        // 通过self访问类型实例的数据
        println!("Rectangle, width: {}, height: {}", self.width, self.height);
    }
}

fn main() {
    let rectangle = Rectangle {
        width: 10,
        height: 20,
    };
    rectangle.draw(); // 通过对象调用trait,隐含传递对象作为self参数
    Draw::draw(&rectangle); // 直接调用trait方法,传递一个对象作为参数
}

例中定义了 Draw trait,为 CircleRectangle 结构体类型实现了 Draw,第 24 行和 25 行分别用对象和 trait 调用 draw 方法,后者需要明确传递类型实例的引用作为参数,这里是 &rectangle

注意第 15 行,self 的类型为 &Rectangle 类型,通过 self 参数访问了实例中的成员。

可以向任意类型添加trait,甚至原生数据类型:

pub trait Who {
    fn who_am_i(&self);
}
impl Who for i32 {
    fn who_am_i(&self) {
        println!("i am i32");
    }
}
fn main() {
    let a=5;
    a.who_am_i();
}

可以通过泛型向所有类型添加trait

pub trait Who {
    fn who_am_i(&self);
}

impl <T> Who for T  {
    fn who_am_i(&self) {
        println!("i am anything");
    }
}
fn main() {
    let a=5;
    let aa=&a;
    let s=String::from("hello");
    a.who_am_i();
    aa.who_am_i();
    s.who_am_i();
}

如果 trait 中的方法签名没有 &self 参数,该方法只能通过实现 trait 的类型加上路径符号 :: 进行调用,而不能通过该类型的实例进行调用:

trait Draw {
    fn draw(); //只能通过类型名调用
}

struct Rectangle {
    width: u32,
    height: u32,
}

impl Draw for Rectangle { //实现接口
    fn draw() {
        println!("Rectangle"); // 不能使用对象的数据
    }
}

fn main() {
    let rectangle = Rectangle {
        width: 10,
        height: 20,
    };
    Rectangle::draw(); // 通过类型名调用trait
    <Rectangle as Draw>::draw(); // 类型名转换为trait,通过trait调用方法
}

默认实现

有时为 trait 中的某些或全部方法提供默认的行为,而不是在每个类型的每个实现中都定义自己的行为是很有用的。这样当为某个特定类型实现 trait 时,可以选择保留或重载每个方法的默认行为。这类似于 C++中的类方法

trait Draw {
    fn draw(&self){ //默认实现
        println!("Draw");
    }
}

struct Rectangle {
    width: u32,
    height: u32,
}
struct Circle {
    radius: u32,
}

impl Draw for Rectangle {
    //实现接口
    fn draw(&self) {
        // 通过self访问类型实例的数据
        println!("Rectangle, width: {}, height: {}", self.width, self.height);
    }
}

impl Draw for Circle { } // 采用默认实现

fn main() {
    let rectangle = Rectangle {
        width: 10,
        height: 20,
    };
    rectangle.draw(); // 通过对象调用trait,隐含传递对象作为self参数
    Draw::draw(&rectangle); // 直接调用trait方法,传递一个对象作为参数
    let circle = Circle { radius: 5 };
    circle.draw();
}

例中 Circle 类型采用了 Draw trait 的默认实现。

空 trait

可以有不指定方法的 trait,这种 trait 起到“标识”特定类型的作用。

fn main() {
    struct MyStruct {
        x: i32,
    }

    trait MyTrait {}

    impl MyTrait for MyStruct {}

    fn main() {
        let s = MyStruct { x: 10 };
    }
}

关联类型

关联类型associated types)是一个将类型占位符与 trait 相关联的方式,这样 trait 的方法签名中就可以使用这些占位符类型。trait 的实现者会针对特定的实现在这个占位符类型指定相应的具体类型。如此可以定义一个使用多种类型的 trait,直到实现此 trait 时都无需知道这些类型具体是什么。

下面是一个带有关联类型的 trait 的例子,它有一个叫做 Item 的关联类型来替代遍历的值的类型。Convert trait 的定义如下例所示:

pub trait Convert {
    type Item;

    fn convert(&self) -> Self::Item;
}

Item 是一个占位符类型,同时 convert 方法定义表明它返回 <Self::Item> 类型的值。这个 trait 的实现者会指定 Item 的具体类型,然而不管实现者指定何种类型,convert 方法都会返回一个此具体类型的值。

关联类型看起来像一个类似泛型的概念,因为它允许定义一个函数而不指定其可以处理的类型。让我们通过在一个 Convert 结构体上实现 convert trait 的例子来检视其中的区别。这个实现中指定了 Item 的类型为 i32

trait Convert {  
    type Item;  
  
    fn convert(&self) -> Self::Item;  
}  
struct MyData {  
    data: u32,  
}  
  
impl Convert for MyData {  
    type Item = i32;  
  
    fn convert(&self) -> Self::Item {  
        self.data as i32  
    }  
}  
  
// 不能为MyData再次实现Convert trait  
// impl Convert for MyData {  
//     type T = f32;  
//  
//     fn convert(&self) -> Self::T {  
//         self.data as f32  
//     }  
// }  
  
fn main() {  
    let my_data = MyData { data: 0 };  
    let a = my_data.convert(); // 正确!  
}

注意,第 29 行 a 并没有类型注解,由 Rust 自动推断。第 18-25 行错误,因为不允许再次为 MyData 实现 Convert trait。

这个语法类似于泛型,实际上 Convert trait 也可以用泛型实现:

pub trait Convert<T> {
    fn convert(&self) -> T;
}

比如:

trait Convert<T> {  
    fn convert(&self) -> T;  
}  
struct MyData {  
    data: u32,  
}  
  
impl Convert<i32> for MyData {  
    fn convert(&self) -> i32 {  
        self.data as i32  
    }  
}  
  
impl Convert<f32> for MyData {  
    fn convert(&self) -> f32 {  
        self.data as f32  
    }  
}  
  
fn main() {  
    let my_data = MyData { data: 0 };  
    // let a = my_data.convert();  // 错误!  
    let a: f32 = my_data.convert(); // 正确!  
}

第 22 行是错误的,因为通过泛型,MyData 有两种 Convert trait 的实现,rust 无法推断出 my_data.convert() 使用的是哪一个,必须像第 23 行那样对返回值类型明确标注。

可以看出两种方式的区别。当使用泛型时,则必须在每一个实现中标注类型,当使用 Convertconvert 方法时,必须提供返回值类型注解来表明希望使用 Convert 的哪一个实现。

通过关联类型,则无需标注类型,因为不能多次实现这个 trait。对于上例使用关联类型的定义,我们只能选择一次 Item 会是什么类型,因为只能有一个 impl Convert for MyData。当调用 Convertconvert 时不必指定返回值类型。

关联类型总是可以用泛型来替代实现,但反之则不一定

泛型和关联类型在很多使用场合是重叠的,但是选择使用泛型还是关联类型是有原因的。泛型允许你实现数量众多的具体 traits (通过改变 T 来支持不同类型),这使得泛型在处理仅是类型参数不同的 trait 时特别有用。

关联类型仅允许 单个实现,因为一个类型仅能实现一个 trait 一次,这可以用来限制实现的数量。比如 Deref trait 有一个关联类型:Target,用于解引用到目标类型。如果可以解引用到多个不同类型,会使让人迷惑,对编译类型推导也带来麻烦。因为一个 trait 仅能被类型实现一次,关联类型带来了表达上的优势。使用关联类型意味着你不必对所有额外类型增加类型标注,这可以被认为是一个工程优势。

trait 作为函数参数

trait 可以作为函数参数,这样,任何实现了该 trait 的类型的实例都可以作为实参。

trait Draw {  //相当于java中的interface,或者C++中的抽象类
    fn draw(&self){ //默认实现
        println!("Draw");
    }
}

struct Rectangle {
    width: u32,
    height: u32,
}
struct Circle {
    radius: u32,
}

impl Draw for Rectangle { //实现接口
    fn draw(&self) {
        println!("Rectangle, width: {}, height: {}", self.width, self.height);
    }  
}

impl Draw for Circle { } // 采用默认实现

fn notify(item: &impl Draw) { // trait作为参数
    item.draw();
}

fn main() {
    let rectangle = Rectangle { width: 10, height: 20 };
    let circle = Circle { radius: 5 };
    notify (&rectangle);
    notify (&circle);
}

例中第 23-25 行定义了 notify 函数,&impl Draw 的含义为将实现了 Draw trait 的类型的实例的引用作为参数。

第 30-31 行调用了 notify 函数,CircleRectangle 类型实现了 Draw trait,因此实例 &ractanglecircle 可以作为参数调用 notify 函数。

特性约束 (Trait Bound)

特性约束的意思是对函数参数作出限定,限定其必须实现了某些 trait。impl <Trait> 语法:

fn notify(item: &impl Draw) { // trait作为参数
    item.draw();
}

它实际上是一种较长形式称为特性约束 (trait bound) 语法的语法糖:

fn notify<T: Draw>(item: &T) {
    item.draw();
}

<T: Draw>trait bound的语法,它与泛型参数声明在一起,位于尖括号中的冒号后面,约束了泛型类型 T 必须实现了特定的Trait—— Draw

impl <Trait> 很方便,适用于短小的例子。更长的 trait bound 则适用于更复杂的场景。例如,可以获取两个实现了 Draw 的参数,使用 impl Trait 的语法看起来像这样:

pub fn notify(item1: &impl Draw, item2: &impl Draw) {
	//...
}

这适用于 item1item2 允许是不同类型的情况(只要它们都实现了 Draw)。不过如果你希望强制它们都是相同类型呢?这必须使用 trait bound 才能做到:

pub fn notify<T: Draw>(item1: &T, item2: &T) {
	//...
}

泛型 T 被指定为 item1item2 的参数限制,如此传递给参数 item1item2 值的具体类型必须一致。

通过 + 指定多个 trait bound

如果 notify 需要显示 item 的格式化形式,同时也要使用 draw 方法,那么 item 就需要同时实现两个不同的 trait:DisplayDraw。这可以通过 + 语法实现:

pub fn notify(item: &(impl Draw + Display)) {
	//...
}

+ 语法也适用于泛型的 trait bound

pub fn notify<T: Draw + Display>(item: &T) {
	//...
}

通过 where 简化 trait bound

然而,使用过多的 trait bound 也有缺点。每个泛型有其自己的 trait bound,所以有多个泛型参数的函数在名称和参数列表之间会有很长的 trait bound 信息,这使得函数签名难以阅读。为此,Rust 有另一个在函数签名之后的 where 从句中指定 trait bound 的语法。所以除了这么写:

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,
{
    unimplemented!()
}

这个函数签名就显得不那么杂乱,函数名、参数列表和返回值类型都离得很近,看起来跟没有那么多 trait bounds 的函数很像。

通过特性约束指定关联类型参数

trait Convert {
    type Item;

    fn convert(&self) -> Self::Item;
}
struct MyData {
    data: u32,
}

impl Convert for MyData {
    type Item = i32;

    fn convert(&self) -> Self::Item {
        self.data as i32
    }
}

fn call_convert<T>(x: T) -> i32
where
    T: Convert<Item=i32>,
{
    x.convert()
}

fn main() {
    let my_data = MyData { data: 5 };
    let value =call_convert(my_data);
    println!("value={}", value);
}

第 20 行,T: Convert<Item=i32> 类型约束不仅指定了泛型 T 必须实现的 trait,还制定了 trait 中的关联类型。从形式上看,Rust 将关联类型看成是 trait 中的泛型。

返回实现了 trait 的类型

也可以在返回值中使用 impl Trait 语法,来返回实现了某个 trait 的类型:

trait Draw {  //相当于java中的interface,或者C++中的抽象类
    fn draw(&self){ //默认实现
        println!("Draw");
    }
}

struct Rectangle {
    width: u32,
    height: u32,
}

impl Draw for Rectangle { //实现接口
    fn draw(&self) {
        println!("Rectangle, width: {}, height: {}", self.width, self.height);
    }  
}

fn return_drawable() -> impl Draw {
    Rectangle { width: 10, height: 20 }
}

fn main() {
    let a=return_drawable();
    a.draw();
}

trait 继承

可以让类型实现多个 trait,这是组合思想。有时需要让类型实现多个 trait,同时这些 trait 之间具有依赖关系,也就是说要求 trait 之间具有继承关系,或者依赖关系,这是继承的思想。被依赖的 trait 称为父(超)traitsupertrait)。

比如我们要输出 Point 结构体的信息的功能,需要 Point 实现 MyPrint trait 得到打印输出功能,MyPrint trait 依赖 MyToString trait,以获取 Point 的字符串信息。

//父trait
trait MyToString {
    fn my_to_string(&self) -> String;
}

//继承了MyToString
trait MyPrint: MyToString {
    // 默认实现,调用了MyToString::my_to_string()
    fn my_print(&self) {
        println!("{}", self.my_to_string());
    }
}

struct Point {
    x: i32,
    y: i32,
}

//实现了MyToString trait
impl MyToString for Point {
    fn my_to_string(&self) -> String {
        format!("({}, {})", self.x, self.y)
    }
}

impl MyPrint for Point {}

fn main() {
    let p = Point { x: 1, y: 2 };
    p.my_print();
}

第 7 -12 行 trait MyPrint: MyToString 就是 trait 继承的语法,通过 : 号表示继承关系。MyPrint 中的 my_print 方法有默认实现,其中调用了父 trait MyToString 中的 my_to_string 方法。

第 20-24 行为 Point 实现了 my_to_string 方法。这是必须的,因为 MyToString 并没有 my_to_string 方法的默认实现。

默认泛型类型参数和运算符重载

当使用泛型类型参数时,可以为泛型指定一个默认的具体类型。如果默认类型就足够的话,这消除了为具体类型实现 trait 的需要。为泛型类型指定默认类型的语法是在声明泛型类型时使用 <PlaceholderType=ConcreteType>

比如标准库 std::ops::Add 中的 Add trait 使用了默认泛型参数:

trait Add<Rhs=Self> {
    type Output;

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

这是一个带有一个方法和一个关联类型的 trait。尖括号中的 Rhs=Self默认类型参数default type parameters)。Rhs(“right hand side” 的缩写)用于定义 add 方法中的 rhs 参数的类型。如果实现 Add trait 时不指定 Rhs 的具体类型,Rhs 的类型将是默认的 Self 类型,也就是在其上实现 Add 的类型。

下例展示了如何在 Point 结构体上使用默认泛型类型实现 Add trait ,实现将两个 Point 实例相加:

use std::ops::Add;  
  
#[derive(Debug, Copy, Clone, 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() {  
    let p1 = Point { x: 1, y: 0 };  
    let p2 = p1.add(Point { x: 2, y: 3 });  
    assert_eq!(p2, Point { x: 3, y: 3 }  
    );  
}

第 9-18 行,为 Point 实现 Add trait 时使用了默认的 Rhs,因为我们希望将两个 Point 实例相加。Add trait 有一个叫做 Output 的关联类型,它用来决定 add 方法的返回值类型。

第 22 行,使用 add 方法将两个 Point 实例的 x 值和 y 值分别相加来创建一个新的 Point

Add trait 是一个特殊的 trait,它实际对应了 + 运算符,当 Rust 对两个类型的变量使用 + 运算符时,实际上调用了类型实现的 Add trait中的 add 方法。因此,上例第 22 行可以改为:

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

这实际上实现了 + 运算符的重载Operator overloading)。

这种情况的一个非常好的例子是使用 运算符重载Operator overloading),这是指在特定情况下自定义运算符(比如 +)行为的操作。注意Rust 并不允许创建自定义运算符或重载任意运算符,不过标准库 std::ops 中所列出的运算符和相应的 trait 可以通过实现运算符相关 trait 来重载。

再来看一个实现 Add trait 时希望自定义 Rhs 类型而不是使用默认类型的例子。这里有两个存放不同单元值的结构体,MillimetersMeters。我们希望能够将毫米值与米值相加,并让 Add 的实现正确处理转换。为此,可以为 Millimeters 实现 Add 并以 Meters 作为 Rhs

use std::ops::Add;

struct Millimeters(u32);
struct Meters(u32);

impl Add<Meters> for Millimeters {
    type Output = Millimeters;

    fn add(self, other: Meters) -> Millimeters {
        Millimeters(self.0 + (other.0 * 1000))
    }
}

fn main() {
    let mm = Millimeters(5);
    let m = Meters(2);
    assert_eq!(2005, (mm + m).0);
}

为了使 MillimetersMeters 能够相加,我们指定 impl Add<Meters> 来设定 Rhs 类型参数的值而不是使用默认的 Self

默认参数类型主要用于如下两个方面:

  • 扩展类型而不破坏现有代码。如果需要为现有 trait 增加类型参数,为其提供一个默认类型将允许我们在不破坏现有实现代码的基础上扩展 trait 的功能。
  • 在大部分用户都不需要的特定情况进行自定义。比如标准库的 Add trait ,大部分时候你会将两个相似的类型相加,不过它提供了自定义额外行为的能力。在 Add trait 定义中使用默认类型参数意味着大部分时候无需指定额外的参数。

newtype 模式用以在外部类型上实现外部 trait

Rrus 有孤儿规则(orphan rule),它规定只有 trait 或类型对于当前 crate 是本地的才可以在此类型上实现该 trait。一个绕开这个限制的方法是使用 newtype 模式newtype pattern),它在一个元组结构体中创建一个新类型。这个元组结构体带有一个希望实现 trait 的类型的字段,该元组结构体是原类型的简单封装,这个封装类型对于 crate 是本地的,这样就可以在这个封装上实现 trait。Newtype 是一个源自 Haskell 编程语言的概念。

例如,如果想要在 Vec<T> 上实现 Display,而孤儿规则阻止我们直接这么做,因为 Display trait 和 Vec<T> 都定义于我们的 crate 之外。可以创建一个包含 Vec<T> 实例的 Wrapper 结构体,接着可以如下例那样在 Wrapper 上实现 Display 并使用 Vec<T> 的值:

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);
}

Display 的实现使用 self.0 来访问其内部的 Vec<T>,因为 Wrapper 是元组结构体而 Vec<T> 是结构体总位于索引 0 的项。接着就可以使用 WrapperDisplay 的功能了。

此方法的缺点是,因为 Wrapper 是一个新类型,它没有定义于其值之上的方法;必须直接在 Wrapper 上实现 Vec<T> 的所有方法,这样就可以代理到 self.0 上 —— 这就允许我们完全像 Vec<T> 那样对待 Wrapper。如果希望新类型拥有其内部类型的每一个方法,为封装类型实现 Deref trait 并返回其内部类型是一种解决方案。如果不希望封装类型拥有所有内部类型的方法 —— 比如为了限制封装类型的行为 —— 则必须只自行实现所需的方法。

动态大小类型和 Sized trait

Rust 创建特定类型变量时需要知道为该类型的变量分配多少空间。rust 中有些类型是固定大小类型,有些是动态大小类型dynamically sized types),后者有时被称为 “DST” 或 “unsized types”,这些类型允许我们处理只有在运行时才知道大小的类型。

之前常用的 str 是一个 DST,注意,不是 &str,而是 str 本身。因为直到运行时都不能知道其大小,也就意味着不能创建 str 类型的变量,也不能获取 str 类型的参数。下面的代码不能工作:

fn main() {
    let s1: str = "Hello there!";
    let s2: str = "How's it going?";
}

Rust 需要知道应该为特定类型的值分配多少内存,同时所有同一类型的值必须使用相同数量的内存。如果允许编写这样的代码,也就意味着这两个 str 需要占用完全相同大小的空间,不过它们有着不同的长度。这也就是为什么不可能创建一个存放动态大小类型的变量的原因。

所以 s1s2 的类型应当是 &str 而不是 str。slice 数据结构仅仅储存了开始位置和 slice 的长度,所以虽然 &T 是一个储存了 T 所在的内存位置的单个值,&str 则是 两个 值:str 的地址和其长度。这样,&str 就有了一个在编译时可以知道的大小:它是 usize 长度的两倍。也就是说,我们总是知道 &str 的大小,而无论其引用的字符串是多长。Rust 中动态大小类型的用法是将动态大小类型的值置于某种指针指向的堆内存中。

为了处理 DST,Rust 提供了 Sized trait 来决定一个类型的大小是否在编译时可知,Rust 隐式的为每一个泛型函数增加了 Sized trait bound,也就是说,对于如下泛型函数定义:

fn generic<T>(t: T) {
}

实际上被当作如下处理:

fn generic<T: Sized>(t: T) {
}

泛型函数默认只能用于在编译时已知大小的类型,然而可以使用如下特殊语法来放宽这个限制:

fn generic<T: ?Sized>(t: &T) {
}

?Sized 上的 trait bound 意味着 “T 可能是也可能不是 Sized” 。注意 ?Trait 语法只能用于 Sized ,而不能用于任何其他 trait。

由于 ?Sized 声明了泛型函数 generic 的泛型类型可能不是 Sized 的,因此函数参数 t 只能是类型 T 的引用类型 &T ,而引用类型 &T 是大小已知且固定的。

观察下例:

use std::fmt::Debug;

fn debug<T: Debug>(t: &T) { // T: Debug + Sized
    println!("{:?}", t);
}

fn main() {
    debug("my str"); // T = str, str: Debug + !Sized
}

编译器报错:

8 |     debug("my str"); // T = str, str: Debug + !Sized
  |     ----- ^^^^^^^^ doesn't have a size known at compile-time

原因显而易见:泛型函数 debug 隐式形式为:fn debug<T: Debug + Sized>(t: &T),而第 8 行 debug("my str") 调用参数类型为 str 这是动态大小的,而不是 Sized 的。应当为泛型 debug 显式增加 ?Sized 约束:

use std::fmt::Debug;  
  
fn debug<T: Debug + ?Sized>(t: &T) { // T: Debug + ?Sized  
    println!("{:?}", t);  
}  
  
fn main() {  
    debug("my str"); // T = str, str: Debug + !Sized  
}

trait 对象

为什么需要 trait 对象

在软件工程中有面向接口编程 的思想,用以提高代码的复用性和灵活性。先看一个例子:

trait Draw {  //相当于java中的interface,或者C++中的抽象类
    fn draw(&self);
}

struct Rectangle {
    width: u32,
    height: u32,
}
struct Circle {
    radius: u32,
}

impl Draw for Rectangle { //实现接口
    fn draw(&self) {
        println!("Rectangle, width: {}, height: {}", self.width, self.height);
    }  
}

impl Draw for Circle {  //实现接口
    fn draw(&self) {
        println!("Circle,radius: {}", self.radius);
    }  
}

fn notify(item: &impl Draw) { // trait作为参数
    item.draw();
}

// 编译错误!返回值不一致
fn init(i: u32)-> impl Draw {
    if i == 0 {
        Rectangle { width: 10, height: 20 }
    } else {
        Circle { radius: 5 }
    }
}

fn main() {
    let shape = init(0);
    notify(shape);
}

第 30-36 行错误,init 函数签名要求返回实现了 Draw trait 的类型,Rust 采取的是静态展开得到单例化的类型,而 if-else 的两个分支返回了不一致的类型,可能是 Rectangle,也可能是 Circle,如果实际返回的是 Rectangle 类型,而使用返回值调用 Circle 类型特有的方法,这是无效的,因此报错。

解决这个问题的方法是指定返回值为 trait 对象:

fn init(i: u32)-> &dny Draw { // dny 关键字表示返回值为 trait 对象
    if i == 0 {
        Rectangle { width: 10, height: 20 }
    } else {
        Circle { radius: 5 }
    }
}

fn main() {
    let shape = init(0);
    notify(shape);
}

注意,上面代码是原理性的,并不能编译。

trait 对象内存结构

trait 对象 在 Rust 中是指使用 dyn 关键字修饰的 trait。比如 SomeTrait 是一个trait, dyn SomeTrait 是 trait 对象,表示实现了 SomeTrait 的某个类型的实例,由于各种类型都可以实现该 trait,trait 对象作为类型来说尺寸是不固定的,是DST类型,因此只能定义trait 对象的引用类型的变量,比如 &dyn SomeTrait ,或者 Box<dyn SomeTrait>,后者涉及智能指针的概念,后面章节会讲到。这里以 &dyn SomeTrait 形式为例进行讨论。

trait 对象的引用类型 &dyn SomeTrait 是一个胖指针,它不仅包括指向某个实现 SomeTrait 的真实对象的指针,还包括一个指向包含所有 SomeTrait 中的函数的虚函数表的指针。可以用下面的伪代码来描述trait对象的应用类型:

pub struct TraitObjectReference { //&dyn SomeTrait
    pub data: *mut (),
    pub vtable: *mut (),
}

struct Vtable {
    destructor: fn(*mut ()),
    size: usize,
    align: usize,
    method: fn(*const ()) -> String,
}

注意,&dyn SomeTrait 类型的含义并不是一个胖指针的引用,其本身就是一个胖指针!这和点像切片的引用类型非常相似。

与切片一样,有时把 trait 对象的引用也称为 trait 对象,请注意根据上下文加以区分。

其中 data 是一个指向实际类型实例的指针, vtable 是一个指向实际类型针对该 trait 实现的虚函数表,如图 1 所示:

da4ef125024c8dc1acfd5dd34ca6aba5.svg

图 1:trait 对象的引用胖指针结构

每个 vtable 中的 destructor 字段都指向一个函数,该函数负责清理 vtable 类型的任何资源。size 和 align 字段存储了被清除类型的实例(ptr 指向的数据)大小以及它的对齐方式(数值宽度)。destructor 、sizealignment 这三个字段是每个 vtable 都共有的类型。method_1 到 method_n 就是在 trait 中定义的方法。比如 trait_object.method_1() 这样的方法调用将从 vtable 中检索出正确的指针,然后对其进行动态调用。

静态分发与动态分发

在 C++语言中,有静态绑定 (编译时绑定)动态绑定 (运行时绑定) 的概念。与之对应,rust 有静态分发 (static dispatch) 和 动态分发 (dynamic dispatch) 的概念。

静态分发

如果实现了特定 trait 的类型的变量作为函数参数采用静态分发,函数形参形式为 x: &impl Draw,比如:

trait Draw {  //相当于java中的interface,或者C++中的抽象类
    fn draw(&self);
}

struct Rectangle {
    width: u32,
    height: u32,
}
struct Circle {
    radius: u32,
}

impl Draw for Rectangle { //实现接口
    fn draw(&self) {
        println!("Rectangle, width: {}, height: {}", self.width, self.height);
    }  
}

impl Draw for Circle { 
    fn draw(&self) {
        println!("Circle,radius: {}", self.radius);
    }  
}

fn notify(item: &impl Draw) { // trait作为参数
    item.draw();
}

fn main() {
    let rectangle = Rectangle { width: 10, height: 20 };
    let circle = Circle { radius: 5 };
    notify (&rectangle);
    notify (&circle);
}

前面提到,第 25行实际上是一种较长形式称为特性约束 (trait bound) 语法的语法糖:

fn notify<T: Draw>(item: &T) {
    item.draw();
}

当第 32 行调用 notify 函数时,编译器在编译时会针对泛型 T 静态展开得到单态化形式的函数调用,形式类似于:

notify_rectangle(item: &Rectangle){
	println!("Rectangle");
}

而第 33 行调用 notify 函数时,编译器在编译时同样会针对泛型 T 静态展开得到单态化形式的函数调用,形式类似于:

notify_circle(item: &Circle){
	println!("Circle");
}

可以看出,调用 notify 函数时,Rust 编译器在编译时就确定了函数参数的类型,两次调用实参分别是 RectangleCircle 的引用,调用 draw 方法是静态分发的,是编译时就明确的。

注意,示例中由于 notify 函数的定义 notify(item: &impl Draw) 明确指定了参数类型, notify(&rectangle) 只能调用 Rectangle 类型实现的 Draw trait 中的方法,而不能调用类型 Rectangle 本身实现的方法(在下一章将介绍直接向类型添加方法的语法)和类型 Rectangle 实现的其他 trait 的方法。

动态分发

函数形参为 trait 对象 时采用动态分发,函数形参形式为 item: &dny Draw

//...
fn notify(item: &dyn Draw) { // trait对象作为参数
    item.draw();
}

fn main() {
	let rectangle: &dyn Draw = &Rectangle { width: 10, height: 20 }; 
	let circle: &dyn Draw = &Circle { radius: 5 };  
	notify(rectangle);  
	notify(circle);
}

注意,Rust 能够自动将实现了 Draw trait的类型的引用类型转换为 Draw trait对象的引用类型,比如第 7、8 行。

调用 notify 函数时编译器在编译时不确定调用哪个具体方法,而是生成负责在运行时确定该调用什么方法的代码,这些代码通过查询虚函数表来定位具体函数,用伪代码描述为:

fn notify(item: &dyn Draw) { // trait对象作为参数
    draw = find_draw_in_vtable(item); // 在item的vtable表中查找draw()函数地址
    call(draw); // 调用draw()函数
}

可以看出,调用 notify 函数时,Rust 编译器在编译时只确定函数参数为 Draw 的 trait 对象的引用,而隐去了是那个类型实现的 Draw trait,两次调用的的实参的类型一样——都是 Draw 的 trait 对象的引用,调用 draw 方法是动态分发的,由程序代码在运行时通过查 vtable 表获得具体的 draw 方法。

这样也可以:

//...
fn notify(item: &dyn Draw) { // trait对象作为参数
    item.draw();
}

fn main() {
	let rectangle = &Rectangle { width: 10, height: 20 }; 
	let circle = &Circle { radius: 5 };  
	notify(rectangle);  
	notify(circle);
}

原因是在调用 notify 函数时 Rust 能够自动将实现了 Draw trait 的类型的引用类型转换为 Draw trait 对象的引用类型。

这样也可以:

fn notify(item: &(impl Draw + ?Sized)) { // trait作为参数
    item.draw();
}

fn main() {
    let rectangle: &dyn Draw = &Rectangle { width: 10, height: 20 };
    let circle: &dyn Draw = &Circle { radius: 5 };
    notify(rectangle);
    notify(circle);
}

为了更清晰地理解 trait 对象的引用类型的内存布局,这里将示例中的 &dyn Draw 的内存布局具象化,如图 2 所示:

a92610286c59ebdb29846f728bfd77c3.svg

图 2 : &dyn Draw 的内存布局

注意,示例中由于 notify 函数的定义 notify(item: &dyn Draw) 明确指定了参数类型, notify(rectangle) 只能调用 Rectangle 类型实现的 Draw trait 中的方法,而不能调用类型 Rectangle 本身实现的方法(在下一章将介绍直接向类型添加方法的语法)和类型 Rectangle 实现的其他 trait 的方法。也就是说,rectangle 是哪个 Trait 对象的引用类型的实例,它的 vtable 中就只包含了该 trait 的方法。比如:

trait X {
    fn x(&self) { println!("from X"); }
}

trait Y {
    fn y(&self) { println!("from Y"); }
}

// 类型B同时实现trait A和trait X
// 类型B还定义自己的方法b
struct A {}
impl A { fn a(&self) { println!("from A"); } }
impl X for A {}
impl Y for A {}

fn main() {
    // tx是X的Trait Object实例,
    // tx保存了指向类型A实例数据的指针和指向vtable的指针
    let tx: &dyn X = &A {};
    tx.x();  // 正确,tx可调用实现自Trait X的方法x()
    // tx.y();  // 错误,tx不可调用实现自Trait Y的方法y()
    // tx.a();  // 错误,tx不可调用自身实现的方法a()

trait 对象使用场景

Trait 对象通常用于以下情况:

  1. 当你需要在运行时而不是编译时处理不同类型的对象,而且它们实现了相同的 Trait。
  2. 当你需要在不同类型之间共享相同的行为,并且在编译时不确定具体的类型。

其它情况不使用 trait 对象,因为动态分发过程是有成本的,效率比静态分发低。

函数返回 trait 对象

了解了 trait 对象的原理,本节开始的代码可以修改为下面这样:

trait Draw {  //相当于java中的interface,或者C++中的抽象类
    fn draw(&self);
}

struct Rectangle {
    width: u32,
    height: u32,
}
struct Circle {
    radius: u32,
}

impl Draw for Rectangle { //实现接口
    fn draw(&self) {
        println!("Rectangle, width: {}, height: {}", self.width, self.height);
    }
}

impl Draw for Circle {  //实现接口
    fn draw(&self) {
        println!("Circle,radius: {}", self.radius);
    }
}

fn notify(item: &dyn Draw) { // trait对象作为参数
    item.draw();
}

// OK!
fn init(i: u32)-> &'static dyn Draw {
    if i == 0 {
        &Rectangle { width: 10, height: 20 }
    } else {
        &Circle { radius: 5 }
    }
}

fn main() {
    let shape = init(0);
    notify(shape);
}

示例编译成功。第 30-36 行,init 函数签名返回的是 trait 对象的引用,'static生命周期注解(生命周期注解后面章节会讲到),表示返回值在整个程序生命周期有效。相应地第 32、33 行返回了 RectangleCircle 类型的引用,被自动转换为 trait 对象的引用,由于 trait 对象是一个胖指针,能够适应实现了特定 trait 的任何具体类型。

对象安全

只有 对象安全object safe)的 trait 才能创建 trait 对象。使得 trait 对象安全存在一些复杂的规则,不过在实践中,只涉及到三条规则。如果一个 trait 同时满足以下约束时,则该 trait 是对象安全的:

  • trait 中的方法返回值类型不为 Self
  • trait 中的方法没有任何泛型类型参数
  • trait 不能继承 Sized

解释说明:

  1. trait 中的方法返回值类型不为 Self

因为把一个类型的对象转为 trait object 后,原始类型信息就丢失了,所以这里的 Self 也就无法确定了。

标准库中的 Clone 特征就不符合对象安全的要求:

pub trait Clone {
    fn clone(&self) -> Self;
}

因为 clone 方法返回了 Self 类型,因此它是对象不安全的。

  1. trait 中的方法没有任何泛型类型参数。

原因在于单态化时会生成大量的函数,很容易导致 trait 对象的 vtable 表内的方法非常多。比如:

trait Trait {
	fn foo<T>(&self, on: T);
}

fn call_foo(x: &dyn Trait) {
	x.foo("hello"); // trait 对象 x 的vtable必须包含非常多的单态化的方法
	x.foo(2);
}
  1. trait 不能继承 Sized。
trait Trait: Sized {
	fn foo<T>(&self, on: T);
}

如果 Trait 继承了 Sized trait,那么 trait object 也是 Sized,而 trait object 是 DST 类型,属于 ?Sized ,所以 trait 不能继承 Sized

几个重要的 trait: Debug 、Clone、Copy、Drop

参考:

Debug

在 Rust 中可以通过 trait 来约定类型的行为,Rust 还提供了派生宏 (derive macro),可以自动生成一些 trait 的实现。例如注解 #[derive(Debug)] 可以为数据类型实现 Debug trait,为类型增加了 debug 能力,这样就可以使用 {:?}println! 格式化打印类型的数据。Debug trait 的定义如下:

pub trait Debug {
    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error>;
}

Clone

std::clone::Clone trait 的定义如下:

pub trait Clone {
    fn clone(&self) -> Self;

    fn clone_from(&mut self, source: &Self) { 
        *self = source.clone()
    }
}

Clone trait 有 clone()clone_from() 两个方法,其中 clone_from () 具有默认实现,所以一般只需要我们实现 clone () 方法即可。

如果类型中包含的其他类型都实现了 Clone trait,就可以通过 derive macro #[derive(Clone)] 为类型自动实现 Clone trait。

clone 操作是深度拷贝,栈内存和堆内存将会被一起拷贝。因为是深度拷贝,因此这个拷贝操作耗费有可能是昂贵的。在 Rust 中的 clone 操作是显式的,需要显式调用。

Copy

std::marker::Copy trait 的定义如下:

pub trait Copy: Clone { }

Copy trait 是一个标记 trait。从 Copy trait 的定义看,如果一个类型要实现 Copy trait,必须实现 Clone trait。

如果类型中包含的其他类型都实现了 Copy trait,就可以通过 derive macro #[derive(Copy)] 为类型自动实现 Copy trait

如果一个类型实现了 Copy trait,那么该类型赋值、传参、函数返回就会使用 Copy 语义,对应的值会被按位浅拷贝,产生新的值。

可以从 Copy trait 的文档查看哪些类型实现了 Copy trait,整理归纳如下:

  • 所有的整形类型,例如 i32
  • 布尔类型 bool
  • 所有的浮点类型,例如 f64
  • 字符类型 char
  • 元组,当且仅当其包含的类型也都实现了 Copy trait,例如 (i32, i32) 实现了 Copy,但 (i32, String) 就没有
  • 数组,当且仅当其内部元素类型实现了 Copy trait
  • ……

Copy 和 Clone 的区别:

CopyClone
trait 定义Copy 继承自 Clone,是 Copy 的类型一定是 Clone 的类型是 Clone 的类型不一定是 Copy 的类型
复制特点Copy 仅对栈内存做按位复制Clone 是深度拷贝,栈和堆都可以是 Clone 的目标
使用方式Copy 是给编译器用的,告诉编译器这个是 Copy 的类型使用 Copy 语义,而不是 Move 语义。Copy 语义在传参、赋值、函数返回值是自动触发。Clone 是给程序员使用的,必须手动调用 clone
使用限制1. 类型必须实现了 Clone 2. 类型内部的其他类型必须都实现了 Copy 3. 不允许自身或其任何部分实现了 Drop trait 的类型使用 Copy类型内部的其他类型必须都实现了 Clone
耗费代价廉价可能是昂贵的,也可能是廉价的

Drop

std::ops::Drop trait 的定义如下:

pub trait Drop {
    fn drop(&mut self);
}

实现 Drop trait 的类型要实现一个 drop 函数,当其所有者变量离开作用域时就会自动调用该方法。

大多数情况下不需要我们手动为类型实现 Drop trait,系统默认会对类型中的内部类型 (例如结构体的每个字段) 做 drop。一般只在需要释放外部资源的场景,这些外部资源是指编译器无法得知的被使用额外资源,可以在 drop 实现中做释放。

DropCopy 是互斥的,Rust 不允许自身或其任何部分实现了 Drop trait 的类型使用 Copy trait,因为 Copy 是栈内存的按位浅拷贝,而 Drop 是为了释放堆内存及额外资源的。

// 编译错误: the trait `Copy` may not be implemented for this typerustcE0204
#[derive(Debug, Clone, Copy)] 
struct Zoo {
    num: i32,
    name: String, // String没有实现Copy, String中的vec:Vec<u8>实现了Drop trait,因此不能为Zoo标记实现Copy trait
}