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 objectDST 类型。

其它

导入 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 即可。

生命周期接口

在函数中,对于像 i32 类型的变量是保存在 Stack 中的,默认实现了 copy 接口,而像 String 这类,除了在 Stack 中保存了元数据,还会在 Heap 中保存真实的数据,在赋值语句中,可能调用的是 copy 或者 move 接口。

使用过程中,比较容易混淆的是 move copy clone drop 几个接口,尤其再结合了闭包捕获的场景,简单整理如下:

  • move copy 主要用在变量赋值、函数入参、函数返回值、闭包捕获场景中。
  • clone 需要显示调用。
  • drop 退出变量作用域时会自动调用。
  • 闭包中使用外部变量会自动捕获。

如果类型没有实现 copy trait 的话(很多基本类型都实现了该语义),那么变量赋值、函数入参、函数返回值都是 move 语义,而 C++11 在出现了右值引用之后才有了 move 语义。

注意,dropcopy 只能二选一,一般来说 copy 不会执行类似 clone 的深度复制。如果两个都实现了,而且 copy 是没有实现深度复制的,那么变量在退出时会执行 drop,该方法会清空栈空间,就会导致重复释放内存。

另外,clonecopy 的 super trait,也就是说,如果要实现 copy 那么就必须要实现 clone,前者用于 Stack 中的按位复制,而后者则可能要进行深度复制。

Partial Move

#[derive(Debug)]
struct Person {
    age: Box<u8>,
    name: String,
}

fn main() {
    let person = Person {
        age: Box::new(20),
        name: String::from("Alice"),
    };
    let Person { name, ref age } = person;
    println!("Name={} Age={}", name, age);
    //println!("Person.Name={}", person.name); // moved无法使用
    println!("Person.Age={}", person.age);     // 非moved操作,仍然可以使用
}

如上的结构体 Person 中,在获取 age 参数时使用的是引用,而 name 则是 move 操作,这就导致变量中的 person.name 没有了所有权,也就是所谓的 Partial Move 操作了。

另外比较常见的是在模式匹配中,例如如下示例。

fn main() {
    let mut data = 10;
    let var = Some(&mut data);
    match var {
        None => (),
        Some(ref z) => {
            println!("{}", z);
        }
    }
    println!("{:?}", var);
}

如果不添加 ref 上述的代码会报错,因为匹配到 Some 之后,会将其中的值读取出来,但是原结构体不变,就是所谓的 Partial Move 了。而添加了 ref 就表示只是引用,并不会发生所有权的转移。

https://stackoverflow.com/questions/69561786/why-is-compiler-reporting-partial-move-in-error-and-not-move

其它

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

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