Not 模式 - 一个自己发明的代数数据类型实现

English Version

TL;DR: 本文总结

代数数据类型(Algebraic Data Type, 缩写 ADT, 有时也被称为 Discriminated Union, Tagged Union, Rust 里称 enum)是一个语言特性。它用来表达一个数据有多个情况且每个情况拥有不同的具体内容。和继承或接口实现相比,有的时候一个数据的情况和结构是确定且稳定的,但这个数据的操作(尤其是分情况做不同处理的操作)是不确定的、可扩展的。代数数据类型就适合这种情形。目前,很多语言还不支持这个语言特性。本文将介绍一个我自己设计的模式来表达这种数据,在方便使用和不易出错间寻找一个平衡。

本文的例子使用 C# 编写,但这个模式本身适用于几乎任何语言。

Not 模式介绍

假如我们有一个身份数据 Identity, 它有三种情况:Guest, UserAdmin. Guest 是一个没有数据关联的单例,User 关联一个 UserId, Admin 关联一个 AdminId.

你可能一直在使用这样一个模式:用一个接口/开放类表示数据,每一个情况是一个子类。

1
2
3
4
5
6
7
8
9
public record Identity
{
public record Guest : Identity { public static Guest Instance { get; } = new Guest(); }
public record User(UserId Id) : Identity;
public record Admin(AdminId Id) : Identity;

// 通过私有构造器来阻止其他类继承 Identity
private Identity() { }
}

然后,你可以通过判断一个 Identity 类型对象是具体哪个类来分情况处理。

1
2
3
4
5
6
7
8
9
10
11
12
switch (identity) {
case Identity.Guest:
return Redirect("/login");
case Identity.Admin admin:
Log.Info($"request from admin: {admin.Id.Value}");
return BadRequest("calling user operation as an admin");
case Identity.User user:
Log.Info($"request from user: {user.Id.Value}");
return PerformAction(user);
default:
throw new NotSupportedException("unsupported identity");
}

这种处理似乎达到了目的,但是它有个缺陷:编写数据处理代码的时候,我们比较容易漏处理一些情况;Code Review 的时候也比较难检查,除非你每次都去看类定义或者准确无误的记住每个类有哪些情况。而代数数据类型的一个特性就是 exhaustiveness checking. 它会对每个分情况处理的代码进行检查,如果存在没有处理的情况就会产生编译错误或警告。Scala 和 Kotlin 一类的语言的 sealed 关键字就能启用这个检查。

Visitor 模式也能确保每个情况被处理,这个模式要求你把每个情况的处理作为一个接口的方法实现或者一个委托(即高阶函数)参数传入一个 Visit 函数。这个模式的缺点在于它无法使用比如 C# 的 pattern matching 的便捷特性,会产生一定的开销,并且对于不了解这个模式的人可读性不足。

所以我设计了另一个模式来实现 exhaustiveness checking, 它会牺牲一定的正确性保障,换来便捷性和可读性,这就是 Not 模式的目的。

这个模式非常简单,你只需要在 Identity 类里面加入如下方法。

1
public Exception Not_Guest_User_Admin() => new Exception($"Not method called on {this}");

这个方法的名称以 Not 开头,接着是每个情况的名字,用下划线分隔,返回一个异常。

有了这个方法,当你需要对它进行分类处理时,你可以先写 default 的分支,抛出 not 方法返回的异常。这个方法可以通过 IDE 的补全功能快速写出来。

1
2
3
4
5
switch (identity) {
// TODO
default:
throw identity.Not_Guest_User_Admin();
}

然后,你就可以从方法名找到有哪些情况。再补全每个情况的处理。

这个模式就这么简单。之所以说它放弃了一些正确性,是因为它依然不阻止你漏处理一个情况,而且 Not 方法本身也可能写错。但是它写错的概率很低,很容易 Code Review,而且你可以用 C# 的 pattern matching 特性。它的可读性也很高,下面是这个例子完整代码。

1
2
3
4
5
6
7
8
9
10
11
12
switch (identity) {
case Identity.Guest:
return Redirect("/login");
case Identity.Admin admin:
Log.Info($"request from admin: {admin.Id.Value}");
return BadRequest("calling user operation as an admin");
case Identity.User user:
Log.Info($"request from user: {user.Id.Value}");
return PerformAction(user);
default:
throw identity.Not_Guest_User_Admin();
}

即便是不了解这个模式的人也能读懂:如果是 Guest, 这么处理;如果是 Admin, 这么处理;如果是 User, 这么处理;否则,抛出一个“这三个都不是”异常。

增加情况

Exhaustiveness checking 的一个作用在于,如果你增加了数据的一个情况,分情况处理数据地方会出现编译错误或警告,来提醒你增加这个情况的处理。而如果你使用 Not 模式,你有两种方法来增加情况。

修改 Not 方法

一种方式是修改 Not 方法,添加新增的情况。注意:不要用 IDE 的重命名方法功能,就直接改方法名。这时,对于没有处理新增情况的代码,它就会有一个“找不到方法”的编译错误。当你看见这样的错误,你就要修改 Not 方法,然后添加对新增情况的处理。这个编译错误能防止你漏掉处理的地方。

Obsolete Not 方法

另一个方式是,是不修改原有的 Not 方法,新增一个完整的 Not 方法,然后给原有的方法标记上 Obsolete 属性,并在信息中写明需要增加哪些情况或者应该用哪个 Not 方法。这样,对于没有处理新增情况的代码,它会有一个编译警告。你可以跟随这些警告来增加未处理的情况。这个方法的优点在于它不会让程序无法编译通过,你可以一步一步的改进。

使用多个字段来表示每个情况并应用 Not 模式

代数数据类型在 C# 中还有一种实现形式,那就是把每个情况当成一个字段。对于合法的数据,恰有一个字段非空。这种实现有两个好处,一是数据的一个情况可以是已有的类型,它不要求这个类型继承自某个类;还有就是数据类型和情况的类型都可以是值类型。

假设 UserId 有两个情况,Int64String. 一个使用多个字段来表示每个情况并应用 Not 模式的例子如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public record struct UserId
{
// 将 init 标记成 private,阻止用户构造两个字段都非空的值。
public Int64? Int64 { get; private init; }
public String? String { get; private init; }

public static explicit operator UserId(Int64 value) => new UserId { Int64 = value };
public static explicit operator UserId(String value) => new UserId { String = value };

public Exception Not_Int64_String() => new Exception($"Not method called on {this}");
}

// ...

// `is {}` 可以用来判断是否为空,并得到一个去掉可空的类型的值
if (userId.Int64 is {} int64Id)
{
// 比如这里,`int64Id` 是 `Int64` 类型而非 `Int64?`
return $"Int64 UserId: {int64Id}";
}
else if (userId.String is {} stringId)
{
return $"Long UserId: {stringId}";
}
else throw userId.Not_Int64_String();

将每个情况当成 object 包装并应用 Not 模式

代数数据类型还有一个实现方式,是对 object 类型进行包装。这个包装通常是值类型。对于每种情况都以 object 的形式进行储存。这个方案除了可以使用已有类型外,还可以减少字段的数目。但如果有的情况是值类型,将它转换为 object 会装箱。

同样以 UserId 为例子,使用这种方式实现代数数据类型并应用 Not 模式的例子如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public record struct UserId
{
// 将 init 标记成 private,阻止用户构造除 `Int64` 和 `String` 的 `Value`。
public object Value { get; private init; }

public static explicit operator UserId(Int64 value) => new UserId { Value = value };
public static explicit operator UserId(String value) => new UserId { Value = value };

public Exception Not_Int64_String() => new Exception($"Not method called on {this}");
}

// ...

switch (uid.Value)
{
case Int64 int64Id:
return $"Int64 ID: {int64Id}";
case String stringId:
return $"String ID: {stringId}";
default:
throw uid.Not_Int64_String();
}

在枚举类型上使用 Not 模式

C# 的枚举类型可以看成代数数据类型的一种特殊情况,即数据的每个情况都不附带内容。可惜的是,C# 的枚举类型也不支持 exhaustiveness checking. 我们可以通过扩展方法在枚举上使用 Not 模式,从而降低某个分支漏处理的错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public enum Identity
{
Guest,
User,
Admin
}

public static class IdentityNotMethod
{
public static Exception Not_Guest_User_Admin(this Identity identity) => new Exception($"Not method called on {identity}");
}

// ...

switch (id)
{
case Identity.Guest:
return Redirect("/login");
case Identity.User:
return PerformAction();
case Identity.Admin:
return BadRequest("calling user operation as an admin");;
default:
throw id.Not_Guest_User_Admin();
}

如果 Not 方法的异常被抛出了

如果你的程序抛出了 Not 方法返回的异常,有以下几种可能的情况。

  • Not 方法本身编写错误,没有包含所有的情况。
  • Not 方法名中提到的情况没有全部处理。
  • 如果 Not 方法被标记 Obsolete, 可能有新增的情况未处理。
  • 如果数据是值类型,一个默认值可能通过 default 或者未初始化的数组构造,这种值通常说明程序有 bug, 比如没有正确初始化数组。
  • 如果数据是枚举,它实际上可以储存所有整数。如果在枚举分支外的整数是合理的,则不应该用 Not 模式;否则,应该寻找这个非法值的来源。

缺点

这个模式有一个缺点,那就是它不适合情况非常多的数据。这种时候,Not 方法就会很长,不方便维护。这种时候可以考虑 Visitor Pattern.

总结

  • 核心思想:使用一个 Not 开头,每个情况都在名字内,返回异常的方法。
  • 使用这个方法的名字来检查是否处理每个情况。
  • 代数数据类型的三种实现方式:继承、每个情况作为一个字段、把情况当成 object 包装。
  • 这三种实现方式,以及枚举,都可以使用 Not 模式。
  • 适合情况数目不多,名字不长的的数据。