起步
此文基本算是 《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 吧!
还不快抢沙发