复合类型
最直观的创造新的复合类型的方式,就是直接将多个类型组合在一起。比如平面上的点都有 X 和 Y 两个坐标,各自都属于 number 类型。因此可以说,平面上的点是由两个 number 类型组合成的新类型。
通常来说,将多个类型直接组合在一起形成新的类型,这样的类型最终的取值范围,就是全部成员类型所有可能的组合值的集合。
![](https://img.haomeiwen.com/i6875152/5679c2f114176904.png)
元组
假如我们需要一个函数来计算两个点之间的距离,可以这样实现:
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 坐标组合在一起作为“点”对待,从而令代码更方便阅读和书写。
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 元组
大部分语言都提供了元组作为内置语法,这里假设在标准库里没有元组的情况下,如何自己实现包含两个元素的元组类型:
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 类型来实现:
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
如果我们允许 dollars
和 cents
变量被公开访问,就有可能导致出现不规范的对象:
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
来避免。同时为了维护方便,一般还需要提供公开的方法对私有的属性进行修改。这些方法通常会包含一定的验证规则,确保修改后的对象状态是合法的。
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()
两个公开的方法,对私有的属性 dollars
和 cents
进行修改。同时这两个方法也会确保对象的状态一直符合我们定义的规则。
另外一种观点是,可以将属性定义成不可变(只读)的。这样属性就可以直接被外部访问,因为只读属性会阻止自身被修改。从而对象状态保持合法。
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)
。
![](https://img.haomeiwen.com/i6875152/ea3eccddddacfc53.png)
枚举
先从一个简单的例子开始,通过类型系统编码周一到周日。我们可以用 0-6 的数字来表示一周的七天,0 表示一周里的第一天。但这样表示并不理想,因为不同的工程师可能对这些数字有不同的理解。有些国家第一天是周日,有些国家第一天是周一。
function isWeekend(dayOfWeek: number): boolean {
return dayOfWeek == 5 || dayOfWeek == 6;
} // 欧洲国家判断是否是周末
function isWeekday(dayOfWeek: number): boolean {
return dayOfWeek >= 1 && dayOfWeek <= 5;
} // 美国判断是否是工作日
上述两个函数是冲突的。若 0 表示周日,则 isWeekend()
是不正确的;若 0 表示周一,则 isWeekday()
是不正确的。
其他的方案是定义一系列常量用来表示一周七天。
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
这类常量在某个模块的某处。因而他们会倾向于自己解释此处的数字。甚至一些人会传入非法的数字参数比如 -1
或 10
。
更好的方案是借助枚举类型。
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 中,可以通过 |
类型操作符来实现,|
允许我们组合多个类型。
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
或者 undefined
。useInput()
函数在调用 parseDayOfWeek()
后再对返回值进行解包操作,输出错误信息或者得到合法值。
Optional 类型:也常被叫做 Maybe 类型,表示一个可能存在的 T 类型值。一个 Optional 类型的实例,可能会包含一个 T 类型的任意值;也可能是一个特殊值,用来表示 T 类型的值不存在。
DIY Optional
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
和错误码。
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
类型实际上是 InputError
和 DayOfWeek
所有可能值的组合。
![](https://img.haomeiwen.com/i6875152/8c4f310c4de150d2.png)
我们应该实现一种 either-or 类型,返回值要么是错误类型,要么是合法的值。
![](https://img.haomeiwen.com/i6875152/987b07939b567994.png)
DIY Either
Either
类型包含了 TLeft
和 TRight
另外两种类型。TLeft
用来存储错误类型,TRight
保存合法的值。
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
中解包出来。
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 中。
网友评论