Trait 使用及原理分析

发布: 2021-04-27   上次更新: 2024-02-24   分类: 编程语言   标签: rust

文章目录

在 Rust 设计目标中,零成本抽象是非常重要的一条,它让 Rust 具备高级语言表达能力的同时,又不会带来性能损耗。零成本的基石是泛型与 trait,它们可以在编译期把高级语法编译成与高效的底层代码,从而实现运行时的高效。这篇文章就来介绍 trait,包括使用方式与三个常见问题的分析,在问题探究的过程中来阐述其实现原理。

本文投稿于 RustMagazine 中文月刊

使用方式

基本用法

Trait 的主要作用是用来抽象行为,类似于其他编程语言中的「接口」,这里举一示例阐述 trait 的基本使用方式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
trait Greeting {
    fn greeting(&self) -> &str;
}

struct Cat;
impl Greeting for Cat {
    fn greeting(&self) -> &str {
        "Meow!"
    }
}

struct Dog;
impl Greeting for Dog {
    fn greeting(&self) -> &str {
        "Woof!"
    }
}

在上述代码中,定义了一个 trait Greeting,两个 struct 实现了它,根据函数调用方式,主要两种使用方式:

  • 基于泛型的静态派发
  • 基于 trait object 的动态派发

泛型的概念比较常见,这里着重介绍下 trait object

A trait object is an opaque value of another type that implements a set of traits. The set of traits is made up of an object safe base trait plus any number of auto traits.

比较重要的一点是 trait object 属于 Dynamically Sized Types(DST,类似于 str ),在编译期无法确定大小,只能通过指针来间接访问,常见的形式有 Box<dyn trait> &dyn trait 等。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
fn print_greeting_static<G: Greeting>(g: G) {
    println!("{}", g.greeting());
}
fn print_greeting_dynamic(g: Box<dyn Greeting>) {
    println!("{}", g.greeting());
}

print_greeting_static(Cat);
print_greeting_static(Dog);

print_greeting_dynamic(Box::new(Cat));
print_greeting_dynamic(Box::new(Dog));

静态派发

在 Rust 中,泛型的实现采用的是单态化(monomorphization),会针对不同类型的调用者,在编译时生成不同版本的函数,所以泛型也被称为类型参数。好处是没有虚函数调用的开销,缺点是最终的二进制文件膨胀。在上面的例子中, print_greeting_static 会编译成下面这两个版本:

1
2
print_greeting_static_cat(Cat);
print_greeting_static_dog(Dog);

动态派发

不是所有函数的调用都能在编译期确定调用者类型,一个常见的场景是 GUI 编程中事件响应的 callback,一般来说一个事件可能对应多个 callback 函数,而这些 callback 函数都是在编译期不确定的,因此泛型在这里就不适用了,需要采用动态派发的方式:

1
2
3
4
5
6
7
trait ClickCallback {
    fn on_click(&self, x: i64, y: i64);
}

struct Button {
    listeners: Vec<Box<dyn ClickCallback>>,
}

impl trait

在 Rust 1.26 版本中,引入了一种新的 trait 使用方式,即:impl trait,可以用在两个地方:函数参数与返回值。 该方式主要是简化复杂 trait 的使用,算是泛型的特例版,因为在使用 impl trait 的地方,也是静态派发,而且作为函数返回值时,数据类型只能有一种,这一点要尤为注意!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
fn print_greeting_impl(g: impl Greeting) {
    println!("{}", g.greeting());
}
print_greeting_impl(Cat);
print_greeting_impl(Dog);

// 下面代码会编译报错
fn return_greeting_impl(i: i32) -> impl Greeting {
    if i > 10 {
        return Cat;
    }
    Dog
}

// | fn return_greeting_impl(i: i32) -> impl Greeting {
// |                                    ------------- expected because this return type...
// |     if i > 10 {
// |         return Cat;
// |                --- ...is found to be `Cat` here
// |     }
// |     Dog
// |     ^^^ expected struct `Cat`, found struct `Dog`

高阶用法

关联类型

在上面介绍的基本用法中,trait 中方法的参数或返回值类型都是确定的,Rust 提供了类型「惰性绑定」的机制,即关联类型(associated type),这样就能在实现 trait 时再来确定类型,一个常见的例子是标准库中的 Iterator,next 的返回值为 Self::Item

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
}

/// 一个只输出偶数的示例
struct EvenNumbers {
    count: usize,
    limit: usize,
}
impl Iterator for EvenNumbers {
    type Item = usize;

    fn next(&mut self) -> Option<Self::Item> {
        if self.count > self.limit {
            return None;
        }
        let ret = self.count * 2;
        self.count += 1;
        Some(ret)
    }
}
fn main() {
    let nums = EvenNumbers { count: 1, limit: 5 };
    for n in nums {
        println!("{}", n);
    }
}
// 依次输出  2 4 6 8 10

关联类型的使用和泛型相似,Iterator 也可使用泛型来定义:

1
2
3
pub trait Iterator<T> {
    fn next(&mut self) -> Option<T>;
}

它们的区别主要在于:

  • 一个特定类型(比如上文中的 Cat)可以多次实现泛型 trait。比如对于 From<T>,可以有 impl From<&str> for Cat 也可以有 impl From<String> for Cat
  • 但是对于关联类型的 trait,只能实现一次。比如对于 FromStr,只能有 impl FromStr for Cat ,类似的 trait 还有 Iterator Deref

Derive

在 Rust 中,可以使用 derive 属性来实现一些常用的 trait,比如:Debug/Clone 等,对于用户自定义的 trait,也可以实现过程宏支持 derive,具体可参考:How to write a custom derive macro? ,这里不再赘述。

常见问题

向上转型(upcast)

对于 trait SubTrait: Base ,在目前的 Rust 版本中,是无法将 &dyn SubTrait 转换到 &dyn Base 的。这个限制与 trait object 的内存结构有关。

Exploring Rust fat pointers 一文中,该作者通过 transmute 将 trait object 的引用转为两个 usize,并且验证它们是指向数据与函数虚表的指针:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
use std::mem::transmute;
use std::fmt::Debug;

fn main() {
    let v = vec![1, 2, 3, 4];
    let a: &Vec<u64> = &v;
    // 转为 trait object
    let b: &dyn Debug = &v;
    println!("a: {}", a as *const _ as usize);
    println!("b: {:?}", unsafe { transmute::<_, (usize, usize)>(b) });
}

// a: 140735227204568
// b: (140735227204568, 94484672107880)

从这里可以看出:Rust 使用 fat pointer(除了一个指针外,还包含另外一些信息,类似于 str ) 来表示 trait object 的引用,分布指向 data 与 vtable,这和 Go 中的 interface 十分类似。可以用下面的伪代码来表示:

https://img.alicdn.com/imgextra/i2/581166664/O1CN01esAA7q1z6A3inQpnF_!!581166664.jpg
trait object reference
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
pub struct TraitObjectReference {
    pub data: *mut (),
    pub vtable: *mut (),
}

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

尽管 fat pointer 导致指针体积变大(无法使用 Atomic 之类指令),但是好处是更明显的:

  1. 由于 vtable 与原始对象是分开的,所以可以为已有类型实现新的 trait(比如 blanket implementations
  2. 调用虚表中的函数时,只需要引用一次,而在 C++ 中,vtable 是存在对象内部的,导致每一次函数调用都需要两次引用,如下图所示:

    https://img.alicdn.com/imgextra/i2/581166664/O1CN01u6ms841z6A3cHRdJw_!!581166664.jpg
    cpp vtable two-level indirect

如果 trait 有继承关系时,vtable 是怎么存储不同 trait 的方法的呢?在目前的实现中,是依次存放在一个 vtable 中的,如下图:

https://img.alicdn.com/imgextra/i4/581166664/O1CN01x8adaQ1z6A3bkyKqY_!!581166664.png
多 trait 时 vtable 示意图

可以看到,所有 trait 的方法是顺序放在一起,并没有区分方法属于哪个 trait,这样也就导致无法进行 upcast,社区内有 RFC 2765 在追踪这个问题,感兴趣的读者可参考,这里就不讨论解决方案了,介绍一种比较通用的解决方案,通过引入一个 AsBase 的 trait 来解决:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
trait Base {
    fn base(&self) {
        println!("base...");
    }
}

trait AsBase {
    fn as_base(&self) -> &dyn Base;
}

// blanket implementation
impl<T: Base> AsBase for T {
    fn as_base(&self) -> &dyn Base {
        self
    }
}

trait Foo: AsBase {
    fn foo(&self) {
        println!("foo..");
    }
}

#[derive(Debug)]
struct MyStruct;

impl Foo for MyStruct {}
impl Base for MyStruct {}

fn main() {
    let s = MyStruct;
    let foo: &dyn Foo = &s;
    foo.foo();
    let base: &dyn Base = foo.as_base();
    base.base();
}

向下转型(downcast)

向下转型是指把一个 trait object 再转为之前的具体类型,Rust 提供了 Any 这个 trait 来实现这个功能。

1
2
3
pub trait Any: 'static {
    fn type_id(&self) -> TypeId;
}

大多数类型都实现了 Any,只有那些包含非 'static 引用的类型没有实现。通过 type_id 就能够在运行时判断类型,下面看一示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use std::any::Any;
trait Greeting {
    fn greeting(&self) -> &str;
    fn as_any(&self) -> &dyn Any;
}

struct Cat;
impl Greeting for Cat {
    fn greeting(&self) -> &str {
        "Meow!"
    }
    fn as_any(&self) -> &dyn Any {
        self
    }
}

fn main() {
    let cat = Cat;
    let g: &dyn Greeting = &cat;
    println!("greeting {}", g.greeting());

    // &Cat 类型
    let downcast_cat = g.as_any().downcast_ref::<Cat>().unwrap();
    println!("greeting {}", downcast_cat.greeting());
}

上面的代码重点在 downcast_ref,其实现为:

1
2
3
4
5
6
7
pub fn downcast_ref<T: Any>(&self) -> Option<&T> {
    if self.is::<T>() {
        unsafe { Some(&*(self as *const dyn Any as *const T)) }
    } else {
        None
    }
}

可以看到,在类型一致时,通过 unsafe 代码把 trait object 引用的第一个指针(即 data 指针)转为了指向具体类型的引用。

Object safety

在 Rust 中,并不是所有的 trait 都可用作 trait object,需要满足一定的条件,称之为 object safety 属性。主要有以下几点:

  1. 函数返回类型不能是 Self(即当前类型)。这主要因为把一个对象转为 trait object 后,原始类型信息就丢失了,所以这里的 Self 也就无法确定了。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    // This trait is object-safe, but these methods cannot be dispatched on a trait object.
    trait NonDispatchable {
     // Non-methods cannot be dispatched.
     fn foo() where Self: Sized {}
     // Self type isn't known until runtime.
     fn returns(&self) -> Self where Self: Sized;
     // `other` may be a different concrete type of the receiver.
     fn param(&self, other: Self) where Self: Sized {}
     // Generics are not compatible with vtables.
     fn typed<T>(&self, x: T) where Self: Sized {}
    }
  2. 函数中不允许有泛型参数。主要原因在于单态化时会生成大量的函数,很容易导致 trait 内的方法膨胀。比如

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    
    trait Trait {
     fn foo<T>(&self, on: T);
     // more methods
    }
    
    // 10 implementations
    fn call_foo(thing: Box<Trait>) {
     thing.foo(true); // this could be any one of the 10 types above
     thing.foo(1);
     thing.foo("hello");
    }
    
    // 总共会有 10 * 3 = 30 个实现
    
  3. Trait 不能继承 Sized。这是由于 Rust 会默认为 trait object 实现该 trait,生成类似下面的代码:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    
    trait Foo {
     fn method1(&self);
     fn method2(&mut self, x: i32, y: String) -> usize;
    }
    
    // autogenerated impl
    impl Foo for TraitObject {
     fn method1(&self) {
         // `self` is an `&Foo` trait object.
    
         // load the right function pointer and call it with the opaque data pointer
         (self.vtable.method1)(self.data)
     }
     fn method2(&mut self, x: i32, y: String) -> usize {
         // `self` is an `&mut Foo` trait object
    
         // as above, passing along the other arguments
         (self.vtable.method2)(self.data, x, y)
     }
    }

    如果 Foo 继承了 Sized,那么就要求 trait object 也是 Sized,而 trait object 是 DST 类型,属于 ?Sized ,所以 trait 不能继承 Sized。更多参考:

总结

本文开篇就介绍了 trait 是实现零成本抽象的基础,通过 trait 可以为已有类型增加新方法,这其实解决了表达式问题,可以进行运算符重载,可以进行面向接口编程等。

希望通过本文的分析,可以让读者更好的驾驭 trait 的使用,对于非 safe 的 trait,能修改成 safe 是最好的方案,否则可以尝试泛型的方式。

参考

评论

欢迎读者通过邮件与我交流,也可以在 MastodonTwitter 上关注我。