

Programming with Types —— 组合类型
source link: https://rollingstarky.github.io/2022/12/15/programming-with-types-compound-types/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Programming with Types —— 组合类型
最直观的创造新的复合类型的方式,就是直接将多个类型组合在一起。比如平面上的点都有 X 和 Y 两个坐标,各自都属于 number 类型。因此可以说,平面上的点是由两个 number 类型组合成的新类型。
通常来说,将多个类型直接组合在一起形成新的类型,这样的类型最终的取值范围,就是全部成员类型所有可能的组合值的集合。
假如我们需要一个函数来计算两个点之间的距离,可以这样实现:
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)
。
先从一个简单的例子开始,通过类型系统编码周一到周日。我们可以用 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
所有可能值的组合。
我们应该实现一种 either-or 类型,返回值要么是错误类型,要么是合法的值。
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 中。
</div
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK