Hoes of Tech

Conceptualization of Technology

Enum, Generic and Templates

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

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

impl Foo for A {
    fn whoami(&self) -> TypeId {
        //注意,这里的内容其实是在编译期就确定了的,
        //因此这个函数并没有执行开销(inline之后就是一个usize的常数)
        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是否真的比编译器的静态检查要冗长?

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