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

如果我們允許 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)

枚舉

先從一個簡單的例子開始,通過類型系統編碼週一到週日。我們可以用 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 所有可能值的組合。

我們應該實現一種 either-or 類型,返回值要麼是錯誤類型,要麼是合法的值。

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章