文 Akisann@CNblogs / zhaihj@Github
本篇文章同时发布在Github上:https://zhaihj.github.io/enum-generic-and-templates.html
在很久之前,我曾经写过(或者说,翻译过)一篇关于OOC里泛型的博客,在那个时候,我对OOC的泛型设计是持否定态度的——相比起OOC的动态泛型,那时的我认为类似C++的泛型更加好用。类型在编译时是确定的,因此编译器可以进行静态类型检查,同时没有执行时的性能损失,也不需要在使用时cast,不会出现错误……总之,似乎没有理由去选择OOC的设计。
在那之后的2~3年里,我也一直都是这么认为的。
当然,Rust也是这样的,因此这几年我也一直很满足,知道最近遇到的问题。
An Example of Deserialization
让我们先来考虑一个简单的场景,有某个服务用Json传送信息,里面包含了一个服务器列表,服务器有几种类型,每一种有不同的属性,比如:
{
"server_list": [
{
"name": "server_a",
"role": "front",
"scale": 10
},
{
"name": "server_b",
"role": "worker",
"is_debug": false,
"restart_time": "23:55",
"restart_type": "everyday"
},
{
"name": "server_c",
"role": "backup",
"scale": "100",
"storage_limit": "24G",
"log_level": "debug"
}
]
}
直接操作json肯定不是好选项,大部分情况下用serde先Deserialize是个不错的办法。
struct Server {
server_list: Vec<...>,
....
}
let server_list : Server = serde_json::from_str(&json_str)?;
....
现在问题就来了,server_list
显然是一个Vec,但它的内容不是一致的——里面其实有数个不同的类型。
并且这种写法并不少见,json,xml,yaml等等都可以这么做。
如果不同类型的属性名称是不同的,那么我们可以把它们全部合并成一个巨大的struct,然后根据role来判断需要哪些field:
struct ServerItem {
name: String,
role: String,
scale: Option<i64>,
is_debug: Option<bool>,
restart_time: Option<String>,
restart_type: Option<String>,
storage_limit: Option<String>,
log_level: Option<String>,
...
}
for item in &server_list.server_list {
match item.role.as_str() {
"front" => {
...
},
"worker" => {
...
},
"backup" => {
...
},
_ => {
unreachable!()
}
}
}
这样我们就能统一的访问这些成员了。当然,每一次访问都需要判断role
,并且要处理大量的Option
,导致代码看起来很冗长。(Rust的Option
的Zero-cost是指内存上的,但并不代表代码上写起来是zore-cost的)
并且,另外一个更重要的问题是——如果不同种类的属性之间有冲突,这个办法就没法用了。比如这里的scale
,在front里他是一个数字,然而在backup里他是一个字符串。这样处理起来就麻烦多了。
当然,serde也能处理这种情况:
fn any_to_str<T, S>(data: &T, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
T: std::fmt::Debug,
{
s.serialize_str(&format!("{:?}", &data))
}
struct ServerItem {
...
#[serde(deserialize_with="any_to_string")]
scale: Option<String>,
}
这样,任何类型的scale
都会转换成字符串,我们可以在后面的处理中根据需要再parse
回数字。
很显然的,这种做法效率很低,并且会导致代码进一步的复杂,如果未来消息里不停的有这种情况,我们要不停的修改这个巨大的struct,并且跟着修改各种对应的parse。并且,随着类型的增加,这个巨大struct会失去维护性——从字面上根本看不出哪些类型拥有哪些属性,我们也无法在deserialize时检查数据是不是正确的了(因为他们全都是Option的)。
Enum Varints
一个比较常见的解决办法就是用Enum了,Rust的Enum Variants可以像Struct一样用自己的成员,因此,对上面的例子我们可以这样写:
#[serde(tag = "role")]
enum ServerItem {
front {
name: String,
scale: i64,
...
},
worker {
name: String,
is_debug: bool,
restart_time: String,
restart_type: String,
...
},
backup {
name: String,
scale: String,
storage_limit: String,
log_level: String,
...
},
}
这样,我们可以把列表Parse成一个ServerItem
的Vec了,每一个属性都是只跟当前的类型有关,不再需要类型转换和Option了。这样,在处理Vec的时候会变得很轻松。
不过,其实还有一个问题——处理的时候我们依然需要判断类型,就像这样:
for item in &server_list.server_list {
match item {
ServerItem::front{name, scale, ...} => {
...
},
ServerItem::worker{name, ...} => {},
serverItem::backup{name, scale, ...} => {},
}
}
在第一次遇到一个ServerItem的时候,判断类型并没有什么问题,然而就算我们已经知道了它的类型,每次用到它还是需要重新来一次:
fn process_front(item: &ServerItem) {
match item {
&ServerItem::front{ name, scale, ...} => {},
_ => { unreachable!() },
}
}
// 我们已经知道这是一个front
process_front(&item);
可以想象到每次改变scope,我们都要重新确认item到底是什么,但我们早就知道了——因此这除了让代码边长之外并没有什么意义。为了避免这种情况,我们需要一些办法。
Enum::as_struct
一个很直白的方法就是:对enum,我们准备很多as_..
的方法,把每个variant都转换成对应的struct。实际上,serde_json的Value就是这么做的。
enum ServerItem { ... }
struct Front { ... }
struct Worker { ... }
struct Backup { ... }
impl ServerItem {
fn is_front(self) -> bool { ... }
fn is_worker(self) -> bool { ... }
fn is_backup(self) -> bool { ... }
fn as_front(self) -> Option<Front> { ... }
fn as_worker(self) -> Option<Worker> { ... }
fn as_backup(self) -> Option<Backup> { ... }
}
这样,我们在处理之前,可以把它们转换成对应的类型:
...
let front = item.as_front();
process_front(&front);
这样下来,后面的处理就变得简洁多了。这也是目前主流的做法。
但还有一个问题,这种处理能不能变得更简洁一些?
Enum Variants as Type
一个很直接的想法就是,让每个Enum Variant都成为单独的类型,这样我们就能把参数定义成这个variant,或者用泛型来处理了,比如:
fn process_front(front_item: ServerItem::Front) {
...
}
显然如果能够这么做,那么上面的问题大都不存在了,我们甚至不需要这种函数,因为在上面的循环里直接处理就已经很清晰了:
for item in server_list.server_list {
// process item
match item {
ServerItem::Front => {
// process item directly
},
...
}
}
在这里,每个item在match之前就已经带着类型了,这里的match仅仅是一个guard,并不涉及类型转换。按照这个设计,下面的写法也是正确的:
enum Foo {
A (i32, i64 ),
B (String, i8),
C,
}
let foo = Foo::A { 10, 20};
match foo {
A | B => {
// handle foo
},
C => {
// do nothing
}
}
到这里,所有熟悉Rust的人都会看出问题——这跟目前的类型系统是有矛盾的。A和B是不同的类型,虽然foo的类型是确定的,但在A|B
的Arm下我们并不知道它到底是哪一种,因此也无法取出它的内部数据。就算写成A(bar, baz) | B (bar, baz)
,这里的bar和baz的类型依然是冲突的,它的类型在编译期不确定,自然也没法这么使用。(纵使给他们不同的名字,我们也不知道到底那个Arm match了,因此每个变量都是Option的,我们还是要挨个判断)
其实,Rust的开发者们从2016年就想给Enum Variants加类型了,但上面这个问题一直是绊脚石。2018年有人重新提起了这个问题,也并没有获得很多正面反馈。
Enum Variants and Generics
这让我想起了过去的OOC。实际上OOC的Generics看起来就像是专门用来解决这个问题的。用Rust的语言来说,其实OOC打算实现这么一个东西:
对于这么一个定义:
enum Foo {
A(i32, i64),
B(String, i8),
}
编译器会把它翻译成:
struct A {
_ano1: i32,
_ano2: i64,
}
struct B {
_ano1: String,
_ano2: i8,
}
trait Foo{
fn whoami(&self) -> TypeId;
}
impl Foo for A {
fn whoami(&self) -> TypeId {
std::any::TypeId::of<A>()
}
}
impl Foo for B {
fn whoami(&self) -> TypeId {
std::any::TypeId::of<B>()
}
}
因此,实际上Foo并不是真正的类型(这里仅仅使用Rust的语言来描述,我们只能用Trait,实际上OOC的定义要更自然一些,更接近一个Meta类型)。当我们使用它的variants时,其实是这样的:
比如下面的代码:
// 实际上,foo的类型是Box<dyn Foo>,它的“实际类型”是A。
let foo = Foo::A(64, 32);
match foo {
// 编译器有foo的所有信息,显然这里是可以判定的,但cast则是由用户完成的。
// 这意味着用户可以故意的把一个A cast成一个B,但这会导致运行时Panic。
Foo::A => process(foo as A),
Foo::B => process2(foo as B),
...
}
其实会被翻译成:
let foo = Box::new(A {64, 32}) as Box<dyn Foo>;
match foo.whoami() {
std::any::TypeId::of<A> => {
let _tmp_foo = (foo as Box<dyn Any>).downcast_ref::<A>();
//这时,_tmp_foo的类型已经是A了。
process(_tmp_foo);
},
std::any::TypeId::of<B> => {
let _tmp_foo = (foo as Box<dyn Any>).downcast_ref::<B>();
process(_tmp_foo);
}
}
当然,这并没有解决所有的问题(尤其是Rust存在的binding问题),但对于大部分的情况,它足够强壮,也足够优雅了——我们有了variants的类型,没有失去类型检查,编译器可以解决绝大部分的转换问题,除了稍微有一点运行时的损耗(但这是必不可少的)。
所以,每次会想起在OOC里发生的争论,我对会回过头来看Rust里的设计,C++的Template是否真的比OOC的Generic优雅?运行时的检查和Cast是否比确定性的生成要受限制?每次用到泛型时写一次cast是否真的比编译器的静态检查要冗长?
三年前,我或许会毫不犹豫的回答“是”,但现在,我又没法下结论了。