文 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是否真的比编译器的静态检查要冗长?

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

Enum, Generic and Templates的更多相关文章

  1. How to Start a Business in 10 Days

    With an executive staffing venture about to open, a business loan from the in-laws gnawing at her co ...

  2. Awesome Django

     Awesome Django    If you find Awesome Django useful, please consider donating to help maintain it. ...

  3. <Effective C++>读书摘要--Templates and Generic Programming<一>

    1.The initial motivation for C++ templates was straightforward: to make it possible to create type-s ...

  4. Asp.Net 将枚举类型(enum)绑定到ListControl(DropDownList)控件

    在开发过程中一些状态的表示使用到枚举类型,那么如何将枚举类型直接绑定到ListControl(DropDownList)是本次的主题,废话不多说了,直接代码: 首先看工具类代码: /// <su ...

  5. .net MVC 中枚举类型Enum 转化成 下拉列表的数据源

    第一次写技术博文,记录下工作中遇到的问题,给自己的知识做个备份,也希望能帮助到其他的同学 最近接手了公司的一个新的项目.有个页面涉及相关设计. 分享一个经常用到的吧. 方法一: 直入主题吧 我们的目的 ...

  6. 适当使用enum做数据字典 ( .net c# winform csharp asp.net webform )

    在一些应用中,通常会用到很多由一些常量来进行描述的状态数据,比如性别(男.女),审核(未审核.已审核)等.在数据库中一般用数字形式来存储,比如0.1等. 不好的做法 经常看到一些应用(ps:最近又看到 ...

  7. C#的Enum——枚举

    枚举 枚举类型声明为一组相关的符号常数定义了一个类型名称.枚举用于“多项选择”场合,就是程序运行时从编译时已经设定的固定数目的“选择”中做出决定. 枚举类型(也称为枚举)为定义一组可以赋给变量的命名整 ...

  8. Enum Helper

    public static class EnumHelper { #region get /// <summary> /// 获得枚举类型所包含的全部项的列表 /// </summa ...

  9. [CareerCup] 14.4 Templates Java模板

    14.4 Explain the difference between templates in C++ and generics in Java. 在Java中,泛式编程Generic Progra ...

随机推荐

  1. Go 每日一库之 go-ini

    简介 ini 是 Windows 上常用的配置文件格式.MySQL 的 Windows 版就是使用 ini 格式存储配置的. go-ini是 Go 语言中用于操作 ini 文件的第三方库. 本文介绍g ...

  2. Elasticsearch系列---搜索执行过程及scroll游标查询

    概要 本篇主要介绍一下分布式环境中搜索的两阶段执行过程. 两阶段搜索过程 回顾我们之前的CRUD操作,因为只对单个文档进行处理,文档的唯一性很容易确定,并且很容易知道是此文档在哪个node,哪个sha ...

  3. sin 与 cos 的用法

    这两个函数使用过程中要,如 sin(x) , x 在这里表示的是弧度,至于弧度要如何计算呢 ? pi / 180 = 弧度 / 角度 内部的参数可以整数或者浮点数,以前面做过的一道题而言,大量的调用函 ...

  4. 继承Exception⭐⭐

    public class ECOrderException : Exception { //第一种类型: throw new ECOrderException { ErrorCode = " ...

  5. java 魔术

    每4个字节都有对应的含义

  6. 简述http协议及抓包分析

    1:HTTP请求头和响应头的格式 1:HTTP请求格式:<request-line><headers><blank line>[<request-body&g ...

  7. Springboot + 持久层框架JOOQ

    简介 官网链接 JOOQ是一套持久层框架,主要特点是: 逆向工程,自动根据数据库结构生成对应的类 流式的API,像写SQL一样 提供类型安全的SQL查询,JOOQ的主要优势,可以帮助我们在写SQL时就 ...

  8. Pandas中merge和join的区别

    可以说merge包含了join的操作,merge支持通过列或索引连表,而join只支持通过索引连表,只是简化了merge的索引连表的参数 示例 定义一个left的DataFrame left=pd.D ...

  9. 【Java并发基础】安全性、活跃性与性能问题

    前言 Java的多线程是一把双刃剑,使用好它可以使我们的程序更高效,但是出现并发问题时,我们的程序将会变得非常糟糕.并发编程中需要注意三方面的问题,分别是安全性.活跃性和性能问题. 安全性问题 我们经 ...

  10. RainbowPlan-Alpha版本发布2

    博客介绍 这个作业属于哪个课程 https://edu.cnblogs.com/campus/xnsy/GeographicInformationScience/ 这个作业要求在哪里 https:// ...