Published on

🦀 Rust 的动态参数:从宝可梦看传递动态参数的三种方法

Authors
  • avatar
    Name
    阿森 Hansen
    Twitter

在阅读文章前,确保你已经了解以下的基本知识:

  • 基本 rust 语法:函数定义,声明和调用
  • rust 的特征和特征对象
  • rust 中泛型的使用和基本语法
  • rust 中枚举 enum 的使用

0 要解决什么问题?

在做项目的过程中,我们经常遇到在定义函数时要动态传递参数类型。

考虑下面这个例子:

struct PlainMessage {
  pub title: String,
  pub content: String
};
fn print_message(foo: PlainMessage) {
  /// ... 打印这个 message
}

以上代码中, print_message 函数只能处理 PlainMessage 类型的消息。但是,如果要处理更多类型的消息,我们该如何处理?

举个例子,现在有这样一个新的消息结构:

// 定义一个新类型的 Message,带有一个标签 Tag
struct TaggedMessage {
  pub tag: String,
  pub title: String,
  pub content: String
}

在解决问题之前,先简单介绍一下 rust 的 “特征”

0.1 简介:rust 中的特征

Rust 不是面向对象的语言,它用“特征”来达成类似的面向对象的特性。

如果用宝可梦的身体形状特征类比:

  • 小火龙和杰尼龟属于“双足兽形”宝可梦
  • 胖丁和臭臭花则是“人形”宝可梦
  • 比比鸟和超音蝠都归于“双翅型”宝可梦

不同宝可梦的体型和它们的体型特征

回到消息的例子,假设目前有两种不同的消息,结构体中字段数量不同,但是他们都可以被打印,这样一来,“可打印”就是一个特征。

// 定义一个“可打印”的特征
trait Printable {
  fn print_message(&self);
}

// 为 PlainMessage 实现可打印特征
impl Printable for PlainMessage {
  fn print_message(&self) {
    println!("{} {}", &self.title, &self.content)
  }
}

// 为 TaggedMessage 实现可打印特征
impl Printable for TaggedMessage {
  fn print_message(&self) {
    println!("{} {} {}", &self.tag, &self.title, &self.content)
  }
}

关于特征更详细的介绍,可看参考文献

1 推荐:使用特征对象(动态的方法)

如果把宝可梦的身体形状视为“特征”,当我们需要一个“双翅型”宝可梦作为参数的时候,则比比鸟和超音蝠都符合条件。所以,“双翅型”就是我们要的特征,它可以作为特征对象来指定参数。

于是,我们可以把“可打印”作为特征对象的特征来指定参数:

// 将特征指定为特征对象作为函数参数
// 注意:特征对象必须使用 Box 智能指针包裹后才能作为参数
// 为什么要多此一举?
// 因为智能指针在编译时无法确定内存长度,使用 Box 包裹后的智能指针可以确定。而 rust 无法接受内存长度不固定的函数声明
fn print_dyn(foo: Box<dyn Printable>) {
  foo.print_message()
}

fn main() {
  let msg = PlainMessage {
    title: "test_title".to_string(),
    content: "test_content".to_string()
  };
  // 用 Box 包裹 msg 对象后才能作为参数传入。
  print_dyn(Box::new(msg));
}

这种方法灵活但是有性能损失,特征对象用起来十分方便,但是也带了性能的负面影响,因为编译器无法在编译时对特征对象参数做优化。

2 推荐:使用泛型(静态的方法)

泛型可以理解成一种代码的“模板”。我们在编写代码时可以不将类型写死。编译时根据调用函数的情况,再指定参数类型。

// 定义一个泛型的方法,指定 T 为泛型,该泛型需要实现 Printable 特征
fn print_generic<T: Printable>(foo: T) {
  foo.print_message()
}

fn main() {
  let msg = PlainMessage {
    title: "test_title".to_string(),
    content: "test_content".to_string()
  };
  // 直接把 msg 参数传入函数即可
  print_generic(msg);
}

泛型和特征对象的不同:编译器确定泛型类型时,是“静态的”。也就是说,在编译器将源代码编译到可执行代码时,会确定真正的参数类型,也叫“单态化”。而特征对象在编译时不会确定方法参数的真正类型。

举个例子,假设我们传入的是 PlainMessage 类型,编译器就会生成 fn print_generic(foo: PlainMessage)函数。同理,如果传入TaggedMessage 编译器就会生成 fn print_generic(foo: TaggedMessage)函数。

这种方法高效但是缺乏灵活性,编译器可在编译时确定参数类型,运行时性能好。缺点是不够灵活。

不够灵活体现在哪里?举个例子,如果函数可以传入两个参数,并用泛型描述参数:

// 以下函数将 left 和 right 相加,并返回相同的数据类型
fn add<T: std::ops::Add<Output = T>>(left: T, right: T) -> T{
  return left+right
}

这个函数中,left 和 right 只能是同一类型(i32 或者 f32),如果 left 和 right 是不同类型,这样的写法是通不过编译的。

3 枚举的同一化处理(静态的方法)

另一种方法是将两种不同的类型用 enum 做同一化处理,然后再作为参数传入

// 用枚举同一化两种不同的参数
enum Message {
  plain(PlainMessage),
  tagged(TaggedMessage)
}

fn print_enum(foo: Message) {
  // 使用 match 表达式来分别处理两种不同的类型
  match foo {
    Message::plain(msg) => {
      msg.print_message()
    },
    Message::tagged(msg) => {
      msg.print_message()
    }
  }
}

fn main() {
  let msg = PlainMessage {
    title: "test_title".to_string(),
    content: "test_content".to_string()
  };
  // 在将 msg 作为参数传入前需要用 Message 枚举包裹好以后,才能作为参数传入
  print_enum(Message::plain(msg));
}

该方法要根据不同类型分开写逻辑,用来解决这个问题比较啰嗦。但是有一个好处,就是可将完全不同的类型统一到一起

在宝可梦的例子里面,就是我们可以把小火龙和比比鸟放在一起作为参数传递,尽管他们之间没有共同的特征。

参考

宝可梦列表(按体形分类) - 神奇宝贝百科,关于宝可梦的百科全书

特征 Trait - Rust语言圣经(Rust Course)

特征对象 - Rust语言圣经(Rust Course)

泛型 Generics - Rust语言圣经(Rust Course)

枚举 - Rust语言圣经(Rust Course)