静态分发和动态分发

发布时间:2023-03-04 18:16
最后更新:2024-06-20 22:39
所属分类:
Rust

Rust中的特征(trait)可以被看作是其他语言中的接口,它其实是一种约束。通过特征可以直接调用实现了这个特征的数据结构中的方法,根据实现形式不同,这种分发存在静态分发和动态分发两种形式。

在使用静态分发和动态分发的时候,数据对象的类型已经被“抹掉”,编译器只知道正在操作的数据对象实现了指定的特征,并且可以调用特征中约定的方法。

静态分发

程序在运行的时候具体调用哪个数据结构中的函数在编译期就可以确定下来的,都属于静态分发。在Rust中定义静态分发一般是通过泛型和impl Trait约束来完成的。例如以下两个示例。

1
2
3
fn run(f: impl Fn(i32) -> i32) -> i32 {
  f(5)
}

在这个示例中,impl Fn(i32) -> i32被作为函数参数类型使用,它指明了函数可以接受一个函数作为参数。除了可以用在函数参数上,impl Trait还可以使用在函数返回值类型上。

1
2
3
fn add_one() -> impl Fn(i32) -> i32 {
  |a| a + 1
}

静态分发在用在泛型约束中就不需要使用impl关键字了,例如把上面的示例改写成泛型约束的形式就是下面这个样子。

1
2
3
4
5
6
fn run<F>(f: F) -> i32
where
  F: Fn(i32) -> i32
{
  f(5)
}

静态分发并不是我们常说的多态,编译器为每一个被泛型参数代替的具体类型都生成了非泛型的函数方法和实现,所以编译器才可能在编译期就确定所要调用的内容。这种处理方法的好处是程序运行速度会很快,但是牺牲的是程序大小。

动态分发

动态分发与静态分发就不一样了,在使用动态分发的时候,编译器是不能在编译期确定被调用的内容的,只能是程序在运行期通过内存寻址才能知道具体调用了什么数据结构的什么方法。动态分发一般使用trait对象来实现,Rust程序在运行时泰国trait抓了中的指针来知晓所需要调用的方法的位置。

动态分发在使用的时候会禁止编译器将一些代码进行内联优化,所以虽然程序的灵活性增加,但是性能将有所降低。

Trait对象

Trait对象其实就是到运行时才能够确定对象指代和内容的对象。这种对象的特点就是其大小是无法在编译期测量的,所以为了能够满足Rust编译器的要求,在使用和访问的时候就必须通过指针来进行。

Trait对象的特征首先是对象安全的,这就要求它必须符合以下规则:

  • 所有的超类也都必须是对象安全的。
  • 超类中不能又Sized约束,即不能存在Self: Sized约束。
  • 必须没有任何关联常量。
  • 所有关联函数都必须可以从Trait对象中调度分发,或者是显式不可调度分发。

但是在具体实践中,一个trait中的所有方法只要满足了以下两个条件,这个triat就是对象安全的。

  • 返回值类型不为Self
  • 方法没有任何泛型类型参数。

实际上Trait对象就是另一种类型的不透明值,实现了一组trait,这一组trait由一个对象安全的基础trait加上任意书两类的自动trait组成。

Trait对象在Rust中的书写方式为dyn关键字后跟一组trait约束,其中要求就是第一个trait必须是基础trait,剩余的trait都必须是自动trait。例如以下trait对象的描述都是合法的。

  • Trait
  • dyn Trait
  • dyn Trait + Send
  • dyn Trait + Send + Sync
  • dyn Trait + 'static或者dyn 'static + Trait

因为trait对象通常都是以引用的形式出现的,所以在作为函数参数使用的时候大多采用&dyn TraitBox<dyn Trait>的形式。

虚表

动态分发在Rust中是通过一张函数指针表来实现的,例如现在有以下定义。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
trait Animal {
  fn walk(&self);
  fn run(&self);
}

#[derive(Clone)]
struct Cat {
  agility: u8,
}

impl Animal for Cat {
  // 这里实现Animal trait中的方法。
}

此时,如果将Cat实例作为trait对象,那么就会在内存中形成以下结构的布局。

Trait对象在内存中的布局结构
Trait对象在内存中的布局结构

当一个trait对象使用了多个trait约束的时候,trait对象中也就将会出现多个虚表指针分别指向几个不同的虚表。

利用动态分发构建多态程序

多态程序在构建许多需要根据不同的选项、配置采用不同的处理策略时是非常有用的。在这种情况下我们是无法预先得知程序中将要处理什么形式的数据的,采用动态分发就是一个比较理想的解决方案。

这里以一个多协议自动解析的代码为例,首先需要定义一个公共的trait用于表示数据包解析所需要的解析过程。

1
2
3
4
5
pub trait Protocol {
  type Error;

  fn analysis(buf: &[u8]) -> Result<(), Self::Error>
}

之后就需要定义几个实现了这个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
37
38
39
40
41
42
43
pub enum ProtocolError {
  // 此处定义错误中的各个variant。
}

impl Error for ProtocolError {
  // 此处定义错误中的实现。
}

pub struct RawBytes {
  // 定义一个直接返回原生内容的数据包结构。
}

impl Protocol for RawBytes {
  type Error = ProtocolError;

  fn analysis(buf: &[u8]) -> Result<(), Self::Error> {
    // 定义RawBytes解析字节数组的方法。
  }
}

pub struct ModBus {
  // 定义ModBus使用的数据包结构。
}

impl Protocol for ModBus {
  type Error = ProtocolError;

  fn analysis(buf: &[u8]) -> Result<(), Self::Error> {
    // 定义ModBus解析字节数组的方法。
  }
}

pub struct IEC104 {
  // 定义IEC104使用的协议数据包结构。
}

impl Protocol for IEC104 {
  type Error = ProtocolError;

  fn analysis(buf: &[u8]) -> Result<(), Self::Error> {
    // 定义IEC104解析字节数组的方法。
  }
}

再接下来就需要定义一个策略函数,用来根据需要生成不同的协议解析器。

1
2
3
4
5
6
7
pub fn protocol_chooser(kind: &str) -> Box<dyn Protocol> {
  match kind {
    "modbus" => Box::new(ModBus { }), // 注意返回值需要是分配在堆上的。
    "iec104" => Box::new(IEC104 { }),
    "raw" | _ => Box::new(RawBytes { }),
  }
}

之后就可以在业务代码中这样来使用了。

1
2
3
4
5
6
7
fn analysis_buffer(analyzer: &dyn Protocol, buf: &[u8]) -> Result<DataPacket, Box<dyn Error>> {
  // 这里analyzer就是一个trait对象,可以直接使用Protocol trait中定义的方法。
  match analyzer.analysis(buf) {
    Ok(data) => todo!(),
    Err(err) => Err(Box::from(err)),
  }
}

索引标签
Rust
特征
静态分发
动态分发
多态