Programming with Types —— 组合类型

复合类型

最直观的创造新的复合类型的方式,就是直接将多个类型组合在一起。比如平面上的点都有 X 和 Y 两个坐标,各自都属于 number 类型。因此可以说,平面上的点是由两个 number 类型组合成的新类型。
通常来说,将多个类型直接组合在一起形成新的类型,这样的类型最终的取值范围,就是全部成员类型所有可能的组合值的集合。

Compound Types

元组

假如我们需要一个函数来计算两个点之间的距离,可以这样实现:

1
2
3
function distance(x1: number, y1: number, x2: number, y2: number): number {
return Math.sqrt((x1 - x1) ** 2 + (y1 - y2) ** 2)
}

上述实现能够正常工作,但并不算完美。x1 在没有对应的 Y 坐标一起出现的情况下,是没有任何实际含义的。同时在应用的其他地方,我们很可能也会遇到很多针对坐标点的其他操作,因此相对于将 X 坐标和 Y 坐标独立地进行表示和传递,我们可以将两者组合在一起,成为一个新的元组类型。
元组能够帮助我们将单独的 X 和 Y 坐标组合在一起作为“点”对待,从而令代码更方便阅读和书写。

1
2
3
4
5
6
type Point = [number, number]

function distance(point1: Point, point2: Point): number {
return Math.sqrt(
(point1[0] - point2[0]) ** 2 + (point1[1] - point2[1]) ** 2);
}

DIY 元组

大部分语言都提供了元组作为内置语法,这里假设在标准库里没有元组的情况下,如何自己实现包含两个元素的元组类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Pair<T1, T2> {
m0: T1;
m1: T2;

constructor(m0: T1, m1: T2) {
this.m0 = m0;
this.m1 = m1;
}
}

type Point = Pair<number, number>;

function distance(point1: Point, point2: Point): number {
return Math.sqrt(
(point1.m0 - point2.m0) ** 2 + (point1.m1 - point2.m1) ** 2);
}

Record 类型

将坐标点定义为数字对,是可以正常工作的。但是我们也因此失去了在代码中包含更多含义的机会。在前面的例子中,我们假定第一个数字是 X 坐标,第二个数字是 Y 坐标。但最好是借助类型系统,在代码中编入更精确的含义。从而彻底消除将 X 错认为是 Y 或者将 Y 错认为是 X 的机会。
可以借助 Record 类型来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Point {
x: number;
y: number;

constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}

function distance(point1: Point, point2: Point): number {
return Math.sqrt(
(point1.x - point2.x) ** 2 + (point1.y - point2.y) ** 2);
}

首要的原则是,最好优先使用含义清晰的 Record 类型,它包含的元素是有明确的命名的。而不是直接将元组传来传去。元组并不会为自己的元素提供名称,只是靠数字索引访问,因而会存在很大的误解的可能性。当然另一方面,元组是内置的,而 Record 类型通常需要额外进行定义。但大多数情况下,这样的额外工作是值得的。

维持不可变性

类的成员函数和成员变量可以被定义为 public(能够被公开访问),也可以被定义为 private(只允许内部访问)。在 TypeScript 中,成员默认都是公开的。
通常情况下我们定义 Record 类型,如果其成员变量是独立的,比如之前的 Point,X 坐标和 Y 坐标都可以独立的进行修改,不会影响到对方。且它们的值可以在不引起问题的情况下变化。像这样的成员被定义成公开的一般不会出现问题。
但是也存在另外一些情况。比如下面这个由 dollar 值和 cents 值组成的 Currency 类型:

  • dollar 值必须是一个大于或者等于 0 的整数
  • cent 值也必须是一个大于或者等于 0 的整数
  • cent 值不能大于 99,每 100 cents 都必须转换成 1 dollar

如果我们允许 dollarscents 变量被公开访问,就有可能导致出现不规范的对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Currency {
dollars: number;
cents: number;

constructor(dollars: number, cents: number) {
if (!Number.isSafeInteger(cents) || cents < 0)
throw new Error();

dollars = dollars + Math.floor(cents / 100);
cents = cents % 100;

if (!Number.isSafeInteger(dollars) || dollars < 0)
throw new Error();

this.dollars = dollars;
this.cents = cents;
}
}

let amount: Currency = new Currency(5, 50);
amount.cents = 300; // 由于属性是公开的,外部代码可以直接修改。从而产生非法对象

上述情况可以通过将成员变量定义为 private 来避免。同时为了维护方便,一般还需要提供公开的方法对私有的属性进行修改。这些方法通常会包含一定的验证规则,确保修改后的对象状态是合法的。

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
26
27
28
29
30
31
32
class Currency {
private dollars: number = 0;
private cents: number = 0;

constructor(dollars: number, cents: number) {
this.assignDollars(dollars);
this.assignCents(cents);
}

getDollars(): number {
return this.dollars;
}

assignDollars(dollars: number) {
if (!Number.isSafeInteger(dollars) || dollars < 0)
throw new Error();

this.dollars = dollars;
}

getCents(): number {
return this.cents;
}

assignCents(cents: number) {
if (!Number.isSafeInteger(cents) || cents < 0)
throw new Error();

this.assignDollars(this.dollars + Math.floor(cents / 100));
this.cents = cents % 100;
}
}

外部代码只能通过 assignDollars()assignCents() 两个公开的方法,对私有的属性 dollarscents 进行修改。同时这两个方法也会确保对象的状态一直符合我们定义的规则。

另外一种观点是,可以将属性定义成不可变(只读)的。这样属性就可以直接被外部访问,因为只读属性会阻止自身被修改。从而对象状态保持合法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Currency {
readonly dollars: number;
readonly cents: number;

constructor(dollars: number, cents: number) {
if (!Number.isSafeInteger(cents) || cents < 0)
throw new Error();

dollars = dollars + Math.floor(cents / 100);
cents = cents % 100;

if (!Number.isSafeInteger(dollars) || dollars < 0)
throw new Error();

this.dollars = dollars;
this.cents = cents;
}
}

不可变对象还有一个优势,从不同的线程对这类数据并发地访问是保证安全的。可变性会导致数据竞争。
但其劣势在于,每次我们需要一个新的值,就必须创建一个新的实例,无法通过修改现有对象得到。而创建新对象有时候是很昂贵的操作。

最终的目的在于,阻止外部代码直接修改属性,以至于跳过验证规则。可以将属性变为私有,对属性的访问完全通过包含验证规则的公开方法;也可以将属性声明为不可变的,在构造对象时执行验证。

either-or 类型

either-or 是另外一种基础的将类型组合在一起的方式,即某个值有可能是多个类型所有合法取值中的任何一个。比如 Rust 语言中的 Result<T, E>,可能是成功的值 Ok(T),也可能是失败值 Err(E)

either-or

枚举

先从一个简单的例子开始,通过类型系统编码周一到周日。我们可以用 0-6 的数字来表示一周的七天,0 表示一周里的第一天。但这样表示并不理想,因为不同的工程师可能对这些数字有不同的理解。有些国家第一天是周日,有些国家第一天是周一。

1
2
3
4
5
6
7
function isWeekend(dayOfWeek: number): boolean {
return dayOfWeek == 5 || dayOfWeek == 6;
} // 欧洲国家判断是否是周末

function isWeekday(dayOfWeek: number): boolean {
return dayOfWeek >= 1 && dayOfWeek <= 5;
} // 美国判断是否是工作日

上述两个函数是冲突的。若 0 表示周日,则 isWeekend() 是不正确的;若 0 表示周一,则 isWeekday() 是不正确的。

其他的方案是定义一系列常量用来表示一周七天。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const Sunday: number = 0;
const Monday: number = 1;
const Tuesday: number = 2;
const Wednesday: number = 3;
const Thursday: number = 4;
const Friday: number = 5;
const Saturday: number = 0;

function isWeekend(dayOfWeek: number): boolean {
return dayOfWeek == Saturday || dayOfWeek == Sunday;
}

function isWeekday(dayOfWeek: number): boolean {
return dayOfWeek >= Monday && dayOfWeek <= Friday;
}

现在的实现看上去好了一些,但仍有问题。单看函数的签名,无法清楚的知道 number 类型的参数的期待值具体是什么。假如一个新接手代码的人刚看到 dayOfWeek: number,他可能不会意识到存在 Sunday 这类常量在某个模块的某处。因而他们会倾向于自己解释此处的数字。甚至一些人会传入非法的数字参数比如 -110

更好的方案是借助枚举类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
enum DayOfWeek {
Sunday,
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday
}

function isWeekend(dayOfWeek: DayOfWeek): boolean {
return dayOfWeek == DayOfWeek.Saturday
|| dayOfWeek == DayOfWeek.Sunday;
}

function isWeekday(dayOfWeek: DayOfWeek): boolean {
return dayOfWeek >= DayOfWeek.Monday
&& dayOfWeek <= DayOfWeek.Friday;
}

Optional 类型

假设我们需要将一个用户输入的 string 值转换为 DayOfWeek,若该 string 值是合法的,则返回对应的 DayOfWeek;若该 string 值非法,则显式地返回 undefined
在 TypeScript 中,可以通过 | 类型操作符来实现,| 允许我们组合多个类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function parseDayOfWeek(input: string): DayOfWeek | undefined {
switch (input.toLowerCase()) {
case "sunday": return DayOfWeek.Sunday;
case "monday": return DayOfWeek.Monday;
case "tuesday": return DayOfWeek.Tuesday;
case "Wednesday": return DayOfWeek.Wednesday;
case "thursday": return DayOfWeek.Thursday;
case "friday": return DayOfWeek.Friday;
case "saturday": return DayOfWeek.Saturday;
default: return undefined;
}
}

function useInput(input: string) {
let result: DayOfWeek | undefined = parseDayOfWeek(input);

if (result === undefined) {
console.log(`Failed to parse "${input}"`);
} else {
let dayOfWeek: DayOfWeek = result;
/* Use dayOfWeek */
}
}

上述 parseDayOfWeek() 函数返回一个 DayOfWeek 或者 undefineduseInput() 函数在调用 parseDayOfWeek() 后再对返回值进行解包操作,输出错误信息或者得到合法值。

Optional 类型:也常被叫做 Maybe 类型,表示一个可能存在的 T 类型值。一个 Optional 类型的实例,可能会包含一个 T 类型的任意值;也可能是一个特殊值,用来表示 T 类型的值不存在。

DIY Optional
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Optional<T> {
private value: T | undefined;
private assigned: boolean;

constructor(value?: T) {
if (value) {
this.value = value;
this.assigned = true;
} else {
this.value = undefined;
this.assigned = false;
}
}

hasValue(): boolean {
return this.assigned;
}

getValue(): T {
if (!this.assigned) throw Error();
return <T>this.value;
}
}

Optional 类型的优势在于,直接使用 null 空类型非常容易出错。因为判断一个变量什么时候能够为空或者不能为空是非常困难的,我们必须在所有代码中添加非空检查,否则就会有引用指向空值的风险,进一步导致运行时错误。
Optional 背后的逻辑在于,将 null 值从合法的取值范围中解耦出来。Optional 明确了哪些变量有可能为空值。类型系统知晓 Optional 类型(比如 DayOfWeek | undefined,可能为空)和对应的非空类型(DayOfWeek)是不一样的。两者是不兼容的类型,因而我们不会将 Optional 类型及其非空类型相混淆,在需要非空类型的地方错误地使用有可能为空值的 Optional。一旦需要取出 Optional 中包含的值,就必须显式地进行解包操作,对空值进行检查。

Result or error

现在尝试扩展前面的 DayOfWeek 例子。当 DayOfWeek 值无法正常识别时,我们不是简单地返回 undefined,而是输出包含更多内容的错误信息。
常见的一个反模式就是同时返回 DayOfWeek 和错误码。

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
enum InputError {
OK,
NoInput,
Invalid
}

class Result {
error: InputError;
value: DayOfWeek;

constructor(error: InputError, value: DayOfWeek) {
this.error = error;
this.value = value
}
}

function parseDayOfWeek(input: string): Result {
if (input == "")
return new Result(InputError.NoInput, DayOfWeek.Sunday);

switch (input.toLowerCase()) {
case "sunday":
return new Result(InputError.OK, DayOfWeek.Sunday);
case "monday":
return new Result(InputError.OK, DayOfWeek.Monday);
case "tuesday":
return new Result(InputError.OK, DayOfWeek.Tuesday);
case "wednesday":
return new Result(InputError.OK, DayOfWeek.Wednesday);
case "thursday":
return new Result(InputError.OK, DayOfWeek.Thursday);
case "friday":
return new Result(InputError.OK, DayOfWeek.Friday);
case "saturday":
return new Result(InputError.OK, DayOfWeek.Saturday);
default:
return new Result(InputError.Invalid, DayOfWeek.Sunday);
}
}

上述实现并不是理想的,原因在于,一旦我们忘记了检查错误代码,没有任何机制阻止我们继续使用 DayOfWeek 值。即便错误代码表明有问题出现,我们仍然可以忽视该错误并直接取用 DayOfWeek
将类型看作值的集合,则上述 Result 类型实际上是 InputErrorDayOfWeek 所有可能值的组合。

Result Type

我们应该实现一种 either-or 类型,返回值要么是错误类型,要么是合法的值。

Either-or Result Type

DIY Either

Either 类型包含了 TLeftTRight 另外两种类型。TLeft 用来存储错误类型,TRight 保存合法的值。

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
26
27
28
29
30
31
32
33
34
35
class Either<TLeft, TRight> {
private readonly value: TLeft | TRight;
private readonly left: boolean;

private constructor(value: TLeft | TRight, left: boolean) {
this.value = value;
this.left = left;
}

isLeft(): boolean {
return this.left;
}

getLeft(): TLeft {
if (!this.isLeft()) throw new Error();
return <TLeft>this.value;
}

isRight(): boolean {
return !this.left;
}

getRight(): TRight {
if (!this.isRight()) throw new Error();
return <TRight>this.value;
}

static makeLeft<TLeft, TRight>(value: TLeft) {
return new Either<TLeft, TRight>(value, true);
}

static makeRight<TLeft, TRight>(value: TRight) {
return new Either<TLeft, TRight>(value, false);
}
}

借助上面的 Either 实现,我们可以将 parseDayOfWeek() 更新为返回 Either<InputError, DayOfWeek>。若函数返回 InputError,则结果中就不会包含 DayOfWeek;若函数返回 DayOfWeek,就可以肯定没有错误发生。
当然,我们需要显式地将结果(或 Error)从 Either 中解包出来。

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
26
27
28
29
30
enum InputError {
NoInput,
Invalid
}

type Result = Either<InputError, DayOfWeek>

function parseDayOfWeek(input: string): Result {
if (input == "")
return Either.makeLeft(InputError.NoInput)

switch (input.toLowerCase()) {
case "sunday":
return Either.makeRight(DayOfWeek.Sunday);
case "monday":
return Either.makeRight(DayOfWeek.Monday);
case "tuesday":
return Either.makeRight(DayOfWeek.Tuesday);
case "wednesday":
return Either.makeRight(DayOfWeek.Wednesday);
case "thursday":
return Either.makeRight(DayOfWeek.Thursday);
case "friday":
return Either.makeRight(DayOfWeek.Friday);
case "saturday":
return Either.makeRight(DayOfWeek.Saturday);
default:
return Either.makeLeft(InputError.Invalid);
}
}

当错误本身并不是“异常的”(大部分情况下,处理用户输入的时候),或者调用某个会返回错误码的系统 API,我们并不想直接抛出异常,但仍旧需要传递正确值或者错误码这类信息。这些时候,最好将这类信息编码到 either value or error 中。

参考资料

Programming with Types