13|类型系统:如何使用trait来定义接口?

3,687 阅读7分钟

正式开始

类型系统的本质:对类型进行定义、检查和处理的工具,保证了某个操作处理的数据类型是我们所希望的 特设多态:包括运算符重载,是指同一种行为有很多不同的实现; 子类型多态:把子类型当成父类型使用,比如 Cat 当成 Animal 使用

trait

trait 是 Rust 中的接口,它定义了类型使用这个接口的行为

基本trait

示例:std::io::Write 接口

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;
    fn write_vectored(&mut self, bufs: &[IoSlice<'_>]) -> Result<usize> { ... }
    fn is_write_vectored(&self) -> bool { ... }
    fn write_all(&mut self, buf: &[u8]) -> Result<()> { ... }
    fn write_all_vectored(&mut self, bufs: &mut [IoSlice<'_>]) -> Result<()> { ... }
    fn write_fmt(&mut self, fmt: Arguments<'_>) -> Result<()> { ... }
    fn by_ref(&mut self) -> &mut Self where Self: Sized { ... }
}
  1. 在 trait 中定义的方法亦被称作关联函数,其可以有缺省的实现
  2. Self 代表当前的类型,比如 File 类型实现了 Write,那么实现过程中使用到的 Self 就指代 File。
  3. self 在用作方法的第一个参数时,实际上是 self: Self 的简写,所以 &self 是 self: &Self, 而 &mut self 是 self: &mut Self
  4. 在实现 trait 的时候,也可以用泛型参数来实现 trait

trait方法是怎样调用的呢

fn write_all(&mut self, buf: &[u8]) -> Result<()>

buf.write_all(b"Hello world!").unwrap(); 为例

  1. 它接受两个参数:&mut self 和 &[u8]
  2. 第一个参数传递的是 buf 这个变量的可变引用
  3. 第二个参数传递的是 b"Hello world!"

带关联类型的 trait

  1. Rust 允许 trait 内部包含关联类型,实现时跟关联函数一样,它也需要实现关联类型
  2. trait 方法里的参数或者返回值,都可以用关联类型来表述,而在实现有关联类型的 trait 时,只需要额外提供关联类型的具体类型即可
pub trait Parse {
    type Error;
    fn parse(s: &str) -> Result<Self, Self::Error>;
}

支持泛型的 trait


pub trait Add<Rhs = Self> {
    type Output;
    #[must_use]
    fn add(self, rhs: Rhs) -> Self::Output;
}

这里 Rhs 默认是 Self,也就是说你用 Add trait ,如果不提供泛型参数,那么加号右值和左值都要是相同的类型

  1. 泛型 trait 可以让我们在需要的时候,对同一种类型的同一个 trait,有多个实现

举例


// Service trait 允许某个 service 的实现能处理多个不同的 Request
pub trait Service<Request> {
    type Response;
    type Error;
    // Future 类型受 Future trait 约束
    type Future: Future;
    fn poll_ready(
        &mut self, 
        cx: &mut Context<'_>
    ) -> Poll<Result<(), Self::Error>>;
    fn call(&mut self, req: Request) -> Self::Future;
}
  1. 注意对于某个确定的 Request 类型,只会返回一种 Response,所以这里 Response 使用关联类型,而非泛型。
  2. 如果有可能返回多个 Response,那么应该使用泛型 Service image.png

trait 的“继承”

  1. 在 Rust 中,一个 trait 可以“继承”另一个 trait 的关联类型和关联函数
  2. 比如 trait B: A,trait B 在定义时可以使用 trait A 中的关联类型和方法

如何做子类型多态?

如果一个对象 A 是对象 B 的子类,那么 A 的实例可以出现在任何期望 B 的实例的上下文中

fn name(animal: impl Animal) -> &'static str; 等于 fn name<T: Animal>(animal: T) -> &'static str;

  1. 这种泛型函数会根据具体使用的类型被单态化,编译成多个实例,是静态分派

trait object

  1. 我们要有一种手段告诉编译器,此处需要并且仅需要任何实现了 Formatter 接口的数据类型
  2. 在 Rust 里,这种类型叫 Trait Object,表现为 &dyn Trait 或者 Box<dyn Trait> dyn 关键字只是用来帮助我们更好地区分普通类型和 Trait 类型,阅读代码时,看到 dyn 就知道后面跟的是一个 trait 了

Trait Object 的实现机理

当需要使用 Formatter trait 做动态分派时,将一个具体类型的引用赋给 &Formatter

image.png

  1. HtmlFormatter 的引用赋值给 Formatter 后,会生成一个 Trait Object
  2. Trait Object 的底层逻辑就是胖指针。其中,一个指针指向数据本身,另一个则指向虚函数表(vtable)
vtable
  1. vtable 是一张静态的表
  2. Rust 在编译时会为使用了 trait object 的类型的 trait 实现生成一张表,放在可执行文件中(一般在 TEXT 或 RODATA 段)
  3. 一个类型+Trait生成一张表

image.png 在这张表里,包含具体类型的一些信息,如 size、aligment 以及一系列函数指针:

  1. 这个接口支持的所有方法
  2. 具体类型的 drop trait,当 Trait object 被释放,它用来释放其使用的所有资源
  3. C++ / Java 指向 vtable 的指针,在编译时放在类结构里,而 Rust 放在 Trait object 中。这也是为什么 Rust 很容易对原生类型做动态分派,而 C++/Java 不行
对象安全的

只有满足对象安全的 trait 才能使用 trait object

  1. 如果 trait 所有的方法,返回值是 Self 或者携带泛型参数,那么这个 trait 就不能产生 trait object
  2. 不允许返回 Self,是因为 trait object 在产生时,原来的类型会被抹去,所以 Self 究竟是谁不知道
  3. 不允许携带泛型参数,是因为 Rust 里带泛型的类型在编译时会做单态化,而 trait object 是运行时的产物,两者不能兼容

小结

image.png

好用链接

  1. io Write 缺省实现
  2. BufBuilder 的Write trait
  3. Rust 正则表达式
  4. 字符串转数字
  5. Add trait
  6. Towner service
  7. gRPC tonic
  8. tokio AsyncWriteExt
  9. futures SteamExt
  10. 对象安全
  11. 迭代器 trait
  12. 实现async fn 的 trait比较困难

延伸阅读

使用 trait 有两个注意事项

  1. 在定义和使用 trait 时,我们需要遵循孤儿规则(Orphan Rule)

    a. trait 和实现 trait 的数据类型,至少有一个是在当前 crate 中定义的

    b. 也就是说,你不能为第三方的类型实现第三方的 trait,当你尝试这么做时,Rust 编译器会报错

  2. Rust 对含有 async fn 的 trait ,还没有一个很好的被标准库接受的实现

vtable 会为每个类型的每个 trait 实现生成一张表

通过以下代码追踪它的行为


use std::fmt::{Debug, Display};
use std::mem::transmute;

fn main() {
    let s1 = String::from("hello world!");
    let s2 = String::from("goodbye world!");
    // Display / Debug trait object for s
    let w1: &dyn Display = &s1;
    let w2: &dyn Debug = &s1;

    // Display / Debug trait object for s1
    let w3: &dyn Display = &s2;
    let w4: &dyn Debug = &s2;

    // 强行把 triat object 转换成两个地址 (usize, usize)
    // 这是不安全的,所以是 unsafe
    let (addr1, vtable1): (usize, usize) = unsafe { transmute(w1) };
    let (addr2, vtable2): (usize, usize) = unsafe { transmute(w2) };
    let (addr3, vtable3): (usize, usize) = unsafe { transmute(w3) };
    let (addr4, vtable4): (usize, usize) = unsafe { transmute(w4) };

    // s 和 s1 在栈上的地址,以及 main 在 TEXT 段的地址
    println!(
        "s1: {:p}, s2: {:p}, main(): {:p}",
        &s1, &s2, main as *const ()
    );
    // trait object(s / Display) 的 ptr 地址和 vtable 地址
    println!("addr1: 0x{:x}, vtable1: 0x{:x}", addr1, vtable1);
    // trait object(s / Debug) 的 ptr 地址和 vtable 地址
    println!("addr2: 0x{:x}, vtable2: 0x{:x}", addr2, vtable2);

    // trait object(s1 / Display) 的 ptr 地址和 vtable 地址
    println!("addr3: 0x{:x}, vtable3: 0x{:x}", addr3, vtable3);

    // trait object(s1 / Display) 的 ptr 地址和 vtable 地址
    println!("addr4: 0x{:x}, vtable4: 0x{:x}", addr4, vtable4);

    // 指向同一个数据的 trait object 其 ptr 地址相同
    assert_eq!(addr1, addr2);
    assert_eq!(addr3, addr4);

    // 指向同一种类型的同一个 trait 的 vtable 地址相同
    // 这里都是 String + Display
    assert_eq!(vtable1, vtable3);
    // 这里都是 String + Debug
    assert_eq!(vtable2, vtable4);
}

精选问答

  1. 对于 Add trait,如果我们不用泛型,把 Rhs 作为 Add trait 的关联类型,可以么?为什么?

    不可以。关联类型只能impl一次,我们需要为Complex实现多个Add<Rhs>

    a. trait 泛型是对同一个数据结构需要有多个不同的实现

    b. trait 的关联类型是,在某个实现里,我需要设定和这个实现相关的类型。其实关联类型就和关联函数一样的

  2. 如下代码能编译通过么,为什么?


use std::{fs::File, io::Write};
fn main() {
    let mut f = File::create("/tmp/test_write_trait").unwrap();
    let w: &mut dyn Write = &mut f;
    w.write_all(b"hello ").unwrap();
    let w1 = w.by_ref();
    w1.write_all(b"world").unwrap();
}

不能。返回类型中的 Self 需要是Sized,而 dyn Write 不是Sized