34

Enum, Generic and Templates

 4 years ago
source link: https://zhaihj.github.io/enum-generic-and-templates.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

在很久之前,我曾经 写过(或者说,翻译过)一篇关于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<...>,
....
}

letserver_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>,
...
}

foritemin&server_list.server_list{
matchitem.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的时候会变得很轻松。

不过,其实还有一个问题——处理的时候我们依然需要判断类型,就像这样:

foritemin&server_list.server_list{
matchitem{
ServerItem::front{name,scale,...}=>{
...
},
ServerItem::worker{name,...}=>{},
serverItem::backup{name,scale,...}=>{},
}
}

在第一次遇到一个ServerItem的时候,判断类型并没有什么问题,然而就算我们已经知道了它的类型,每次用到它还是需要重新来一次:

fn process_front(item: &ServerItem){
matchitem{
&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{...}

implServerItem{
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>{...}
}

这样,我们在处理之前,可以把它们转换成对应的类型:

...
letfront=item.as_front();
process_front(&front);

这样下来,后面的处理就变得简洁多了。这也是目前主流的做法。 但还有一个问题,这种处理能不能变得更简洁一些?

Enum Variants as Type

一个很直接的想法就是,让每个Enum Variant都成为单独的类型,这样我们就能把参数定义成这个variant,或者用泛型来处理了,比如:

fn process_front(front_item: ServerItem::Front){
...
}

显然如果能够这么做,那么上面的问题大都不存在了,我们甚至不需要这种函数,因为在上面的循环里直接处理就已经很清晰了:

foriteminserver_list.server_list{
// process item
matchitem{
ServerItem::Front=>{
// process item directly
},
...
}
}

在这里,每个item在match之前就已经带着类型了,这里的match仅仅是一个guard,并不涉及类型转换。按照这个设计,下面的写法也是正确的:

enum Foo{
A(i32,i64),
B(String,i8),
C,
}

letfoo=Foo::A{10,20};
matchfoo{
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,
}

// 每一个Enum都会有自己的Trait,不过它们的定义都是一样的
traitFoo{
fn whoami(&self)-> TypeId;
}

implFooforA{
fn whoami(&self)-> TypeId{
//注意,这里的内容其实是在编译期就确定了的,
//因此这个函数并没有执行开销(inline之后就是一个usize的常数)
std::any::TypeId::of<A>()
}
}
implFooforB{
fn whoami(&self)-> TypeId{
std::any::TypeId::of<B>()
}
}

因此,实际上Foo并不是真正的类型(这里仅仅使用Rust的语言来描述,我们只能用Trait,实际上OOC的定义要更自然一些,更接近一个Meta类型)。当我们使用它的variants时,其实是这样的:

比如下面的代码:

// 实际上,foo的类型是Box<dyn Foo>,它的“实际类型”是A。
letfoo=Foo::A(64,32);
matchfoo{
// 编译器有foo的所有信息,显然这里是可以判定的,但cast则是由用户完成的。
// 这意味着用户可以故意的把一个A cast成一个B,但这会导致运行时Panic。
Foo::A=>process(fooasA),
Foo::B=>process2(fooasB),
...
}

其实会被翻译成:

letfoo=Box::new(A{64,32})asBox<dynFoo>;
matchfoo.whoami(){
std::any::TypeId::of<A>=>{
let_tmp_foo=(fooasBox<dynAny>).downcast_ref::<A>();
//这时,_tmp_foo的类型已经是A了。
process(_tmp_foo);
},
std::any::TypeId::of<B>=>{
let_tmp_foo=(fooasBox<dynAny>).downcast_ref::<B>();
process(_tmp_foo);
}
}

当然,这并没有解决所有的问题(尤其是Rust存在的binding问题),但对于大部分的情况,它足够强壮,也足够优雅了——我们有了variants的类型,没有失去类型检查,编译器可以解决绝大部分的转换问题,除了稍微有一点运行时的损耗(但这是必不可少的)。

所以,每次会想起在OOC里发生的争论,我对会回过头来看Rust里的设计,C++的Template是否真的比OOC的Generic优雅?运行时的检查和Cast是否比确定性的生成要受限制?每次用到泛型时写一次cast是否真的比编译器的静态检查要冗长?

三年前,我或许会毫不犹豫的回答“是”,但现在,我又没法下结论了。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK