Rust Trait 详解

2023-10-16 language rust

Rust 中的 Trait 有点像其它语言的接口,不过实际上略有不同。

简介

如下是一个简单的示例,包含了模板以及 trait 相关,使用模板时可以进行约束,如果比较简单那么可以通过 <T: XXX + YYY> 的方式指定,或者采用类似如下的方式,通过 where 进行声明。

trait Shape<T> {
    fn area(&self) -> T;
}

struct Rectangle<T> {
    width: T,
    height: T,
}

impl<T> Shape<T> for Rectangle<T>
where
    T: std::ops::Mul<Output = T> + Copy,
{
    fn area(&self) -> T {
        self.width * self.height
    }
}

fn main() {
    let rect = Rectangle {
        width: 30,
        height: 50,
    };
    println!("The area is {}", rect.area());
}

孤儿规则

当为某类型实现某个 Trait 时,必须要保证类型或者 Trait 至少有一个在当前作用域中定义,通过这个规则保证其它人使用你的库时不会破坏库中的函数逻辑,也就是所谓的孤儿规则 (Orphan Rule)。简单来说,允许如下操作:

  • 自定义类型实现外部的 trait,这也是最常用,例如实现标准库的 Display Default 等。
  • 对外部类型实现自定义的 trait,从而某种类型可以支持自定义的实现。

注意,当实现了三方的 trait 后,在使用时,需要同时将对应 trait 引入,尤其是某些场景下不是直接使用的。通过这种方式,防止重名 trait 发生歧义,否则会有类似如下的报错。

error[E0599]: the method `map` exists for struct `xxx`, but its trait bounds were not satisfied
... ...
  = help: items from traits can only be used if the trait is in scope

其它还可以参考 关联类型、生命周期特征 等基本概念。

动态转发

Rust 中的转发支持两种:A) 基于泛型的静态派发,在编译阶段完成类型的确定以及替换,运行性能相对要好;B) 基于 trait object 的动态派发,在运行时确定,有些场景下必须要使用。

为了区分两者,在 RFC 2113 中新增了 dyn 关键字,主要是为了更加明显的标识 trait object 。

trait Shape {
    fn area(&self) -> f64;
}

struct Rectangle {
    width: f64,
    height: f64,
}
impl Shape for Rectangle {
    fn area(&self) -> f64 {
        self.width * self.height
    }
}

const PI: f64 = 3.1415926;
struct Circle {
    radius: f64,
}
impl Shape for Circle {
    fn area(&self) -> f64 {
        self.radius * self.radius * PI
    }
}

fn get_area_box(s: Box<dyn Shape>) -> f64 {
    s.area() // auto deref
}

fn get_area(s: &dyn Shape) -> f64 {
    s.area()
}

fn main() {
    let rect = Rectangle {
        width: 10.0,
        height: 2.0,
    };
    println!(
        "Rectangle {}, Circle {}",
        get_area(&rect),
        get_area_box(Box::new(Circle { radius: 3.0 }))
    );
}

如果直接取指针就是普通指针,大小是 64bit,当手动通过 as 转换为 trait object 后就成了胖指针,会携带额外信息,也就是所谓的虚函数表,包含了具体的调用地址信息。

use std::mem;

trait Shape {
    fn area(&self) -> f64;
}

struct Rectangle {
    width:  f64,
    height: f64,
}
impl Shape for Rectangle {
    fn area(&self) -> f64 {
        self.width * self.height
    }
}

const PI: f64 = 3.1415926;
struct Circle {
    radius: f64,
}
impl Shape for Circle {
    fn area(&self) -> f64 {
        self.radius * self.radius * PI
    }
}

fn dump_trait(p: &dyn Shape) {
    let (data, vtable) : (usize, usize) = unsafe {mem::transmute(p)}; // 强制获取内部数据
    println!("TraitObject    [data:{:#x}, vtable:{:#x}]", data, vtable);
    unsafe {
        let v: *const usize = vtable as *const () as *const usize;
        println!("data in vtable [{:#x}, {:#x}, {:#x}, {:#x}]", *v, *v.offset(1), *v.offset(2), *v.offset(3));
    }
}

fn main() {
    let rect = Rectangle{width: 10.0, height: 2.0};

    let p_rect = &rect; // 8Bytes
    let p_shape = p_rect as &dyn Shape; // 16Bytes
    println!("sizeof(rect) {}, sizeof(shape) {}", mem::size_of_val(&p_rect), mem::size_of_val(&p_shape));

    println!("Circle::area {:#x}", Circle::area as usize);
    println!("Rectangle::area {:#x}", Rectangle::area as usize);
    dump_trait(&rect);
}

通过如上方式可以将虚函数表打印出来。

impl trait

这是在 Rust 1.26 版本中引入的,可以用在函数的参数和返回值,主要是简化 trait 的使用,算是范型的特例版,因为这些地方都是静态派发。

fn get_area_impl(s: impl Shape) -> f64 {
    s.area()
}
get_area_impl(react);
get_area_impl(circle);

如下的方式采用的时动态派发,所以编译时会报错。

fn get_area_impl_deps(i: i32) -> impl Shape {
    if i > 10 {
        return react;
    }
    circle
}

Object Safe

并不是所有的 trait 都可用作 trait object 使用,需要满足 object safety 属性才可以,主要有以下几点:

  • 函数不能返回 Self,因为对象转为 trait object 后,原始类型信息就丢失了,所以这里的 Self 也就无法确定了。
  • 函数中不允许有范型参数,主要原因在于单态化时会生成大量的函数,很容易导致 trait 内的方法膨胀。
  • trait 不能继承 Sized,继承 Sized 会同时要求 trait object 也是 Sized,而 trait object 是 DST 类型。

其它

导入 Trait

Rust 中有个规则,如果要使用 trait 中的方法,需要将对应的 trait 引入到当前 Scope 中。

这主要是因为,很多的 trait 存在签名冲突,此时,某个对象调用相应方法时无法确定如何选择,所以,就简单使用当前 Scope 中的 trait 方法。例如,如下的几个标准库 trait 都包含相同的 fmt 函数签名。

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

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

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

例如如下的示例需要导入 std::fmt::Write 才可以。

use std::collections::HashMap;
use std::fmt::Write;

fn main() {
    let mut properties = HashMap::new();
    properties.insert("hoodies.test", "foobar");
    properties.insert("hoodies.env", "test");

    let mut output = String::new();
    write!(output, "{:?}", properties).unwrap();
    println!("{}", output);
}

在实现时对类型进行了约束,需要支持 std::ops::Mul 的 trait,而且输入和输出的数据类型是相同的,所以实现 std::ops::Mul<Output = T> 的 trait 即可。

常用技巧

如上已经介绍了泛型特征 Generic Traits、类型关联特征 Associated Type Traits,还有如下的常见用法:

  • 特征是可以支持继承的。
  • Marker Traits 在 std::marker 模块中定义,不包含任何方法,用于获取一些编译期间的保障。
  • 使用时存在两个比较特殊的生命周期声明:A) 'static 整个应用生命周期;B) '_ 编译过程自动推导。