不要用 boxed trait objects

Rust 2021-09-25 7777 字 29 浏览 点赞

起步

此文基本算是 《Don't use boxed trait objects》 的中译,但又不全是 《Don't use boxed trait objects》 的中译。

什么是 boxed trait 对象 ?

通常来说,rust 中的 trait 类似于 go 里的 interface —— 一个存放 n(n>=0 ) 个方法的集合,而 go 借助 interface 这一概念,很容易实现“多态”的效果。

看一个 golang 版本的“小鸟飞”示例:

package main

import (
    "flag"
    "fmt"
)

type Bird interface {
    fly()
}

// Woodpecker 啄木鸟
type Woodpecker struct {}

func (Woodpecker) fly()  {
    fmt.Println("啄木鸟 在飞...")
}


// Cuckoo 杜鹃鸟
type Cuckoo struct {}

func (Cuckoo) fly()  {
    fmt.Println("杜鹃鸟 在飞...")
}

func GetOneKindBirdByName(name string) Bird {  // 这里返回的是 Bird interface
    switch name {
    case "woodpecker":
        return Woodpecker{}
    case "cuckoo":
        return Cuckoo{}
    default:
        panic("IncompleteError") // 尚未实现
    }
}

var name = flag.String("name", "woodpecker/cuckoo", "输入鸟的名字")

func main() {
    flag.Parse()
    bird := GetOneKindBirdByName(*name)
    bird.fly()
}

运行效果如下:

PS D:\code\go-test> .\go-test.exe -h
Usage of D:\code\go-test\go-test.exe:
  -name string
        输入鸟的名字 (default "woodpecker/cuckoo")
PS D:\code\go-test> .\go-test.exe -name=woodpecker
啄木鸟 在飞...  # 此时 bird 的具体类型是 Woodpecker
PS D:\code\go-test> .\go-test.exe -name=cuckoo
杜鹃鸟 在飞...  # 此时 bird 的具体类型是 Cuckoo
PS D:\code\go-test>

也就是说,不到执行完 GetOneKindBirdByName 函数,我们总是不知道 main 函数中变量 bird 的具体类型。


既然 trait ≈ interface,是不是也可以照葫芦画瓢一个 rust 版的“小鸟飞”呢?像下面这样:

use clap::{App, Arg};

trait Bird {
    fn fly(&self);
}

struct Woodpecker {}

impl Bird for Woodpecker {
    fn fly(&self) {
        println!("啄木鸟 在飞...")
    }
}

struct Cuckoo {}

impl Bird for Cuckoo {
    fn fly(&self) {
        println!("杜鹃鸟 在飞...")
    }
}

// !!!不可正常运行的代码
fn get_one_kind_bird_by_name(name: String) -> Bird {
    match name.as_str() {
        "cuckoo" => Cuckoo {},
        "woodpecker" => Woodpecker {},
        _ => panic!("IncompleteError"),
    }
}

fn main() {
    // 解析命令行参数
    let opts = App::new("choose one kind bird")
        .arg(
            Arg::with_name("name")
                .short("name")
                .long("name")
                .value_name("woodpecker/cuckoo")
                .help("输入鸟的名字")
                .takes_value(true),
        )
        .get_matches();
    
    let name = opts.value_of("name").unwrap().into();
    let bird = get_one_kind_bird_by_name(name);
    bird.fly();
}

上述的写法跟 go 版的如出一辙,看起来很合理的样子。可编译会报错,报错信息里反复强调 doesn't have a size known at compile-time

49 | fn get_one_kind_bird_by_name(name: String) -> Bird {
   |                                               ^^^^ doesn't have a size known at compile-time
   ...
70 |     let bird = get_one_kind_bird_by_name(name);
   |         ^^^^ doesn't have a size known at compile-time
   ...
70 |     let bird = get_one_kind_bird_by_name(name);
   |                ^^^^^^^^^^^^^^^^^^^^^^^^^ doesn't have a size known at compile-time

这是 rust 的特点,它无法忍受栈上面有个不知道大小的变量 —— 其他语言也不能忍受,只是 rust 不会帮你做隐式处理。我们只需要把未知大小的变量放到堆上,再用栈上的已知大小的指针指过去就可以了。有请 Box。

// 可正常运行的代码
fn get_one_kind_bird_by_name(name: String) -> Box<dyn Bird> {
    match name.as_str() {
        "cuckoo" => Box::new(Cuckoo {}),
        "woodpecker" => Box::new(Woodpecker {}),
        _ => panic!("IncompleteError"),
    }
}

运行效果如下:

$ ./rust-code -h
choose one kind bird 

USAGE:
    rust-code [OPTIONS]

FLAGS:
    -h, --help       Prints help information
    -V, --version    Prints version information

OPTIONS:
    -n, --name <woodpecker/cuckoo>    输入鸟的名字
$ ./rust-code --name=woodpecker
啄木鸟 在飞...
$ ./rust-code --name=cuckoo
杜鹃鸟 在飞...

简单总结一下。当需要用接口变量接收一个实例时,go 只需要声明这个变量是什么接口类型即可:

// go
var bird Bird = Woodpecker{}

而 rust 不但需要明确是什么接口(trait),还需要明确这个实例要在堆上:

// rust
let bird: Box<dyn Bird> = Box::new(Woodpecker{});

这就是 boxed trait 对象,你大可将其类比为 go 中的接口实例。

Box<dyn Trait> 有什么问题吗?

如果你对 go 的接口有一定了解你就知道,interface 的存在还有一个意义:隔离,“屏蔽”掉这个实例与接口无关的那部分内容。无可厚非,这样做好像没错。可如果我们需要原来的具体类型呢?这好办,用类型断言

// Woodpecker 啄木鸟
type Woodpecker struct {
    name string
}

func main() {
    var bird Bird = Woodpecker{name: "啄木鸟"}
    if woodpecker, ok := bird.(Woodpecker); ok {
        fmt.Println(woodpecker.name)
    }
    // 你不能 fmt.Println(bird.name)
}

rust 不支持类型断言,但可以用 downcast 还原类型。这需要在 trait 中添加 fn as_any(&self) -> &dyn Any

use std::any::Any;

trait Bird {
    fn fly(&self);
    fn as_any(&self) -> &dyn Any;
}

struct Woodpecker {
    name: String,
}

impl Bird for Woodpecker {
    fn fly(&self) {
        println!("啄木鸟 在飞...")
    }
    // 实现 as_any 方法
    fn as_any(&self) -> &dyn Any {
        self
    }
}

fn main() {
    let bird: Box<dyn Bird> = Box::new(Woodpecker{name: "啄木鸟".into()});
    // 还原类型
    let woodpecker = bird.as_any().downcast_ref::<Woodpecker>().unwrap();
    println!("{}", woodpecker.name);
    // 你不能 println!("{}", bird.name)
}

你会觉得莫名其妙吗?Bird trait 与 as_any 之间本来毫无关系,我们对鸟的定义是通过能不能“飞(fly)”判断的,as_any 插足进来破坏了 Bird trait 在设计上的纯粹。

倒也不是不能跑……但我们还可以寻求其他方法。

结构体泛型

我们试试 “wrapper + 结构体泛型” 这种解决方案。

struct Woodpecker {
    name: String,
}

struct Cuckoo {
    nick: String,
}

// ...  省略掉接口的定义与实现

struct BirdWrapper<B: Bird> {
    bird: B
}

fn main() {
    let woodpecker = BirdWrapper{bird: Woodpecker{name: "啄木鸟".into()}};
    woodpecker.bird.fly();
    println!("{}", woodpecker.bird.name);  // 调用 name
    
    let cuckoo = BirdWrapper{bird: Cuckoo{nick: "杜鹃鸟".into()}};
    cuckoo.bird.fly();
    println!("{}", cuckoo.bird.nick);      // 调用 nick
}

既能达到“多态”效果,又确保了原类型(Woodpecker / Cuckoo)不会丢失,仿佛很不错哦!从易用性的角度,还可以为 BirdWrapper 实现 Bird trait。

impl<B: Bird> Bird for BirdWrapper<B> {
    fn fly(&self) {
        self.bird.fly();
    }
}

fn main() {
    let woodpecker = BirdWrapper{bird: Woodpecker{name: "啄木鸟".into()}};
    woodpecker.fly();
    
    let cuckoo = BirdWrapper{bird: Cuckoo{nick: "杜鹃鸟".into()}};
    cuckoo.fly();
}

所有实现了 Bird trait 的 struct 都可以被 BirdWrapper 包装,如果你不想这样 —— 如果你要的是“只允许 Woodpecker、Cuckoo 被包装”,可以用 rust enum 限制:

// ... 省略 Woodpecker,Cuckoo 定义与接口实现

enum BirdWrapper {
    Woodpecker(Woodpecker),
    Cuckoo(Cuckoo),
}

impl Bird for BirdWrapper {
    fn fly(&self) {
        match self {
            BirdWrapper::Woodpecker(woodpecker) => woodpecker.fly(),
            BirdWrapper::Cuckoo(cuckoo) => cuckoo.fly(),
        }
    }
}

fn get_one_kind_bird_by_name(name: String) -> BirdWrapper {
    match name.as_str() {
        "cuckoo" => BirdWrapper::Cuckoo(Cuckoo{nick: "啄木鸟".into()}),
        "woodpecker" => BirdWrapper::Woodpecker(Woodpecker{name: "啄木鸟".into()}),
        _ => panic!("IncompleteError"),
    }
}

fn main() {
    let bird = get_one_kind_bird_by_name("cuckoo".into());
    bird.fly();
}

总结

尽管本文的题目叫 “不要用 boxed trait objects”,但本文并不想表达这种观点。只是内容大量参考了 《Don't use boxed trait objects》,延用标题是致敬原作者的一种表现。

所以我没有在这里强调 boxed trait 运行时会带来一定的开销,以及 “wrapper + 结构体泛型” 可以在编译时计算大小的优势。等等之类,这些都不重要。本文的存在仅出于一个目的:就是告诉 go --转--> rust 者 —— 或出于喜爱也好,或出于好奇也好 —— 在 “泛型” 的世界里,我们还有其他选择。

那就期待 go 1.18 吧!

感谢



本文由 Guan 创作,采用 知识共享署名 3.0,可自由转载、引用,但需署名作者且注明文章出处。

还不快抢沙发

添加新评论