在rust中模拟子类型和“多态”

77 阅读7分钟

这里的多态加了引号,因为这里谈的是一种非常狭义的多态。要真的利用rust实现或者提供面向对象接口,还需要做更多的工作。

所谓面向对象除了用来表达各个类型之间的关系,很多时候也是为了让某个地方能够填入多种不同的类型,进而调用不同的实现。在目前主流的面向对象编程语言中,这样的代码都是合法的:

Animal a = new Dog();
a.DoSomething();
void F(Animal a)
{
    a.DoSomething();
}

这里主要有两点

  1. 一个要求填入Animal类型的地方,也可以填入所有从Animal派生的类型
  2. 可以在Animal类型的变量上调用在所有子类型上定义的同名方法(可能重写过的虚方法)

如果不构造一个完整的面向对象系统的话,最简单直接的就是下述两种方式来实现这种行为。

enum

Rust中的枚举类型不(只)是简单的“有名字的常量集合”。和很多语言中的枚举类型不同,rust的enum可以给variant关联一些数据。

为了实现这种多态,我们可以定义多个variant,每个variant对应一个逻辑上的子类型:

enum Animal {
    Dog(Dog), // Dog和Cat等类型是具体的struct
    Cat(Cat),
}

如果你确实需要,你也可以构建多层次的类型结构:

enum Animal {
    Mammal(Mammal),
}
enum Mammal {
    DOg(Dog),
}

这样一来,任何需要Animal为参数的地方,我们也可以用具体的类型的值构造一个Animal类型的值。

方法的分派

为了模拟面向对象的虚方法,我们需要在enum上定义一个统一的方法:

impl Animal {
    fn make_sound(&self) {
        match self {
            Dog(d) => d.make_sound(),
            // ...
        }
    }
}

这纯粹就是静态分派,我们需要处理每一种情况,并明确指定要调用的方法。

当然也可以说是有个好处就是由于你是直接调用具体的方法,所以这些方法的名字可以随便取。

问题

最大的问题就是啰嗦。

定义、构造时啰嗦

如果你要让每一个variant都对应一个具体的类型你就要重复写一个名字两次。并且在构造具体的enum对象时也很麻烦:

Animal::Mammal::Dog(Dog::new())

当然你可以写一些方法来完成构造或者转换,这些方法写起来都不复杂,但是非常烦人。

判别类型时啰嗦

虽然enum天生就应该搭配match,但是如果你在很多时候只需要检查一个枚举类型的值是否属于某个variant的话,这样写就很罗嗦:

match e {
    Dog d => todo!(),
    _ => panic!(),
}

如果你的enum涉及多个层次,那么就会更冗长。

当然,可以用内置的matches!简化一下:

assert!(matches(e, Dog(_)))

此宏返回一个布尔值。

转换成具体的variant关联的值也很啰嗦

成也模式匹配,败也模式匹配。如果你需要尝试将一个枚举类型的值转换成具体variant关联的值,那么你也没有太多的选择:

match a {
    Dog d => d,
    _ => panic!(),
}
​
if let Animal::Dog(d) {
    todo!()
}

还是那句话,你可以,但是很麻烦。

如果涉及的variant不多,并且没有复杂的层次结构,那么用enum来实现这种效果也行。你可以考虑使用一些库来避免手写那些样板代码。比如spire_enum对于上面提到的这种模式来说就可以减少很多粗活累活。

dyn Trait

含有dyn Trait成分的类型称为trait object type。dyn T无法单独作为类型使用,它通常会作为引用、指针的一部分。

用trait object来模拟子类型和实现多态相对来说就要容易一些,加上1.86引入的trait object upcast也让这种操作更加方便:

trait Animal{
    fn move();
}
trait Mammal: Animal{}
fn f(a: &dyn Animal) {
    a.move();
}
​
let d = Dog::new();
let m: &dyn Mammal = &d;
f(m);

这种情况下我们不再定义具体的类型,而是用trait去表达一种约束、合同、契约。然后用dyn Trait去实现真正的动态分派(毕竟dyn就是dynamic)。实现了特定trait的类型的实例都可以放到需要dyn T类型的地方。已经比较接近很多面向对象语言的做法。另一方面,也可以通过supertrait约束实现者同时实现多个trait来模拟类型层次。

更多有关dyn Trait的内容可以看一下我之前写的另一篇文章

dyn Traitenum怎么选

你可能会想,dyn Traitenum有这么多相似之处,那么到底要怎么选?

我很喜欢在rust论坛上看到的一段精辟总结:

dyn Trait是开集,enum是闭集

如何理解?

enum强调,此处的值必然是此类型定义中的若干情况之一。而dyn Trait则表明,它可以是任何类型,而我通常不关心。

也许你还是觉得没说明白,那么请你想象你在开发一个供大家使用的库。在你发布的特定版本中,你的enum虽然是可供用户使用的,但是用户并没有办法为其添加更多的variant,任何使用此enum的地方仍然可以确定它所有的可能性。是为“闭”。

而你公开的一个trait以及任何参数类型为dyn Trait的地方,用户都可以随时让更多的类型去实现你公开的这个trait。你的库函数中使用&dyn Trait等trait object类型的地方可以接受任意用户自己实现的类型的实例,而你的库函数并不关系具体的实现。是为“开”。

当然除此之外,代码简洁性、对象大小可能也是你需要考虑的点。

为什么要选?

也不是一定要在这两者间作出选择。仔细想想,有时候你实际上也不是真的想抽象出类的层次,你只是单纯想定义一个类来容纳几个相关类型或者说只是想表达“这里的值有几种可能性”(也就是所谓“和类型”)。这个时候选enum是没错的。

但如果说enum的某个variant代表一系列有共同特点的类型,你就不一定要用另一个enum来包装了。你完全可以放一个Box<dyn T>之类的东西。这样既让enum保持简洁,又可以实现真正的动态分派,也能在需要的时候体现层次结构:

enum Vehicle {
    Car(Box<dyn Car>),
    Aircraft(Box<dyn Aircraft>),
    Watercraft(Box<dyn Watercraft>),
}

别忘了impl Trait

你可能并不经常见到impl Trait,但它在一定程度和前面两位可以起到类似的作用。

不过它和前面两位不一样的是,它并不算是真正的类型。它只能出现在函数参数和返回值处:

fn f(c: impl Clone) -> impl Clone {
    c.clone()
}

这是一个非常无聊的函数,但是它是合法的。它唯一做的一件事就是把传入的任意实现了Clone的值克隆一份返回。

如果一个函数有这样的参数,那么在函数中就只能调用定义在这个trait中的方法。

从函数返回一个impl Trait时,你也只能调用trait中定义的方法。但要注意的是,虽然你可以把函数返回的impl Trait绑定到一个名字上,但是你不能显式地给这个名字标注为impl Trait

当然这个特性很多时候其实是为了函数、闭包、迭代器准备的,这些东西的类型如果要手写很有可能会非常难写。一定程度上impl Trait就像是一种简写,它本身也是静态处理的。