美文网首页
Programming with Types —— 组合类型

Programming with Types —— 组合类型

作者: rollingstarky | 来源:发表于2022-12-15 20:41 被阅读0次

复合类型

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

Compound Types

元组

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

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

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

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() 两个公开的方法,对私有的属性 dollarscents 进行修改。同时这两个方法也会确保对象的状态一直符合我们定义的规则。

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

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 表示一周里的第一天。但这样表示并不理想,因为不同的工程师可能对这些数字有不同的理解。有些国家第一天是周日,有些国家第一天是周一。

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 这类常量在某个模块的某处。因而他们会倾向于自己解释此处的数字。甚至一些人会传入非法的数字参数比如 -110

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

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 或者 undefineduseInput() 函数在调用 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 类型实际上是 InputErrorDayOfWeek 所有可能值的组合。

Result Type

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

Either-or Result Type
DIY Either

Either 类型包含了 TLeftTRight 另外两种类型。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 中。

参考资料

Programming with Types

相关文章

网友评论

      本文标题:Programming with Types —— 组合类型

      本文链接:https://www.haomeiwen.com/subject/fndlqdtx.html