Programming with Types —— 高階類型(Functor、Monad)

通用的 map 實現

map 是函數式編程中非常常見的一類接口,可以將某個函數操作應用到一系列元素上。一個通用的 map() 實現如下:

function* map<T, U>(iter: Iterable<T>, func: (item: T) => U):
    IterableIterator<U> {
    for (const value of iter) {
        yield func(value);
    }
}

上述實現主要針對可迭代對象,可以將函數 func(類型爲 (item: T) => U)應用給可迭代對象 iter 中的每一個元素。
爲了使 map() 函數的場景更爲通用,func 的參數 item: T 理應能夠接收更多類型的值,比如 Option<T>

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;
    }
}

從邏輯上看,將一個類型爲 (value: T) => U 的函數 map 到 Optional<T> 類型,如果該 Optional<T> 裏面包含一個類型爲 T 的值,則返回值應該是包含 UOptional<U> 類型;若 Optional<T> 並不包含任何值,則 map 操作應該返回一個空的 Optional<U>

下面是支持 Optional 類型的 map 實現:

namespace Optional {
    export function map<T, U>(
        optional: Optional<T>, func: (value: T) => U): Optional<U> {
        if (optional.hasValue()) {
            return new Optional<U>(func(optional.getValue()));
        } else {
            return new Optional<U>();
        }
    }
}

另一種簡單的通用類型 Box<T> 及其 map 實現:

class Box<T> {
    value: T;

    constructor(value: T) {
        this.value = value
    }
}

namespace Box {
    export function map<T, U>(
        box: Box<T>, func: (value: T) => U): Box<U> {
        return new Box<U>(func(box.value));
    }
}

將類型爲 (value: T) => U 的函數 map 到 Box<T>,返回一個 Box<U>Box<T>T 類型的值會被取出來,傳遞給被 map 的函數,再將結果放入 Box<U> 中返回。

處理結果 or 傳遞錯誤

假設我們需要實現一個 square() 函數來計算某個數字的平方,以及一個 stringify 函數將數字轉換爲字符串。示例如下:

function square(value: number): number {
    return value ** 2;
}

function stringify(value: number): string {
    return value.toString();
}

還有一個 readNumber() 函數負責從文件中讀取數字。當我們需要處理輸入數據時,有可能會遇到某些問題,比如文件不存在或者無法打開等。在上述情況下,readNumber() 函數會返回 undefined

function readNumber(): number | undefined {
    /* Implementation omitted */
    return 2
}

如果我們想通過 readNumber() 讀取一個數字,再將其傳遞給 square() 處理,就必須確保 readNumber() 返回的值是一個實際的數字,而不是 undefined。一種可行的方案就是藉助 if 語句將 number | undefined 轉換爲 number

function process(): string | undefined {
    let value: number | undefined = readNumber();
    if (value == undefined) return undefined;
    return stringify(square(value));
}

square() 接收數字類型的參數,因而當輸入有可能是 undefined 時,我們需要顯式地處理這類情況。但通常意義上講,代碼的分支越少,其複雜性就越低,就更易於理解和維護。
另一種實現 process() 的方式就是,並不對 undefined 做任何處理,只是將其簡單地傳遞下去。即只讓 process() 負責數字的處理工作,error 則交給後續的其他人。

可以藉助 爲 sum type 實現的 map()

namespace SumType {
    export function map<T, U>(
        value: T | undefined, func: (value: T) => U): U | undefined {
        if (value == undefined) {
            return undefined;
        } else {
            return func(value);
        }
    }
}

function process(): string | undefined {
    let value: number | undefined = readNumber();
    let squaredValue: number | undefined = SumType.map(value, square)
    return SumType.map(squaredValue, stringify);
}

此時的 process() 實現不再包含分支邏輯。將 number | undefined 解包爲 number 並對 underfined 進行檢查的操作由 map() 負責。

同時 map() 是通用的函數,可以直接在其他 process 函數中對更多不同類型的數據使用(如 string | undefined),減少重複代碼。

版本一(不借助 map):

function squareSumType(value: number | undefined): number | undefined {
    if (value == undefined) return undefined;
    return square(value);
}

function squareBox(box: Box<number>): Box<number> {
    return new Box(square(box.value))
}

function stringifySumType(value: number | undefined): string | undefined {
    if (value == undefined) return undefined;
    return stringify(value)
}

function stringifyBox(box: Box<number>): Box<string> {
    return new Box(stringify(box.value));
}

版本二(藉助 map):

let x: number | undefined = 1;
let y: Box<number> = new Box(42);
console.log(SumType.map(x, stringify))
console.log(Box.map(y, stringify))
console.log(SumType.map(x, square))
console.log(Box.map(y, square))

Functor 定義

Functor:對於任意的泛型,比如 Box<T>,能夠通過 map() 操作將函數 (value: T) => U 應用給 Box<T>,並返回一個 Box<U>

又或者說,Functor 是支持某種 map() 函數的任意類型 H<T>。該 map() 函數接收 H<T> 作爲參數,一個從 TU 的函數作爲另一個參數,最終返回 H<U>
以更面向對象一點的形式來表現的話,參考如下代碼(當然這段代碼是編譯不通過的,因爲 TypeScript 不支持高階類型,如 <H<T>>):

interface Functor<H<T>> {
    map<U>(func: (value: T) => U): H<U>;
}

class Box<T> implements Functor<Box<T>> {
    value: T;

    constructor(value: T) {
        this.value = value;
    }

    map<U>(func: (value: T) => U): Box<U> {
        return new Box(func(this.value));
    }
}

Functors for functions

實際上還存在針對函數的 Functor。

namespace Function {
    export function map<T, U>(
        f: (arg1: T, arg2: T) => T, func: (value: T) => U)
        : (arg1: T, arg2: T) => U {
        return (arg1: T, arg2: T) => func(f(arg1, arg2));
    }
}

function add(x: number, y: number): number {
    return x + y;
}

function stringify(value: number): string {
    return value.toString();
}

const result: string = Function.map(add, stringify)(40, 2);
console.log(result)

Monads

在前面的例子中,只有第一個函數 readNumber() 有可能返回錯誤(undefined)。藉助 Functor,square()stringify() 可以不經修改地正常調用,若 readNumber() 返回 undefined,該 undefined 不會被處理,只是簡單地傳遞下去。
但是假如鏈條中的每一個函數都有可能返回錯誤,又該如何處理呢?

假設我們需要打開某個文件,讀取其內容,再將讀取到的字符串反序列化爲一個 Cat 對象。
負責打開文件的 openFile() 函數可能返回一個 Error 或者 FileHandle。比如當文件不存在、文件被其他進程鎖定或者用戶沒有權限讀取文件,都會導致返回 Error
還需要一個 readFile() 函數,接收 FileHandle 作爲參數,返回一個 Error 或者 String。比如有可能內存不足導致文件無法被讀取,返回 Error
最後還需要一個 deserializeCat() 函數接收 string 作爲參數,返回一個 Error 或者 Cat 對象。同樣的道理,string 有可能格式不符合要求,無法被反序列化爲 Cat 對象,返回 Error

所有上述函數都遵循一種“返回一個正常結果或者一個錯誤對象”的模式,其返回值類型爲 Either<Error, ...>

declare function openFile(path: string): Either<Error, FileHandle>;
declare function readFile(handle: FileHandle): Either<Error, string>;
declare function deserializeCat(serializedCat: string): Either<Error, Cat>;

只是爲了方便舉例,上述函數並不包含具體的實現代碼。同時 Either 類型的實現如下:

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)
    }
}

最終將上述各個函數連接起來的 process 函數類似下面這樣:

function readCatFromFile(path: string): Either<Error, Cat> {
    let handle: Either<Error, FileHandle> = openFile(path);
    if (handle.isLeft()) return Either.makeLeft(handle.getLeft());
    let content: Either<Error, string> = readFile(handle.getRight());
    if (content.isLeft()) return Either.makeLeft(content.getLeft());
    return deserializeCat(content.getRight());
}

就像在上一個例子中對 process 函數做的那樣,我們可以實現一個類似的 map() 函數,將 readCatFromFile() 中的所有分支結構和錯誤檢查都轉移到通用的 map() 中。
按照普遍的約定,Either<TLeft, TRight> 中的 TLeft 包含錯誤對象,map() 只會將其不做改動地傳遞下去。只有當 TRight 存在時,map() 纔會對 Either 應用給定的函數。

namespace Either {
    export function map<TLeft, TRight, URight>(
        value: Either<TLeft, TRight>,
        func: (value: TRight) => URight): Either<TLeft, URight> {
        if (value.isLeft()) return Either.makeLeft(value.getLeft());
        return Either.makeRight(func(value.getRight()));
    }
}

上述 map() 實現的問題在於,當我們調用 openFile() 得到返回值 Either<Error, FileHandle>,接下來就需要一個類型爲 (value: FileHandle) => string 的函數從 FileHandle 讀取文件內容。
但是實際上的 readFile() 函數的返回類型不是 string,而是 Either<Error, string>

當我們調用

let handle: Either<Error, FileHandle> = openFile(path);
let content: Either<Error, string> = Either.map(handle, readFile);

會導致爆出 Type 'Either<Error, Either<Error, string>>' is not assignable to type 'Either<Error, string>'. 錯誤。

正確的實現應該是如下形式的 bind() 方法:

namespace Either {
    export function bind<TLeft, TRight, URight>(
        value: Either<TLeft, TRight>,
        func: (value: TRight) => Either<TLeft, URight>
    ): Either<TLeft, URight> {
        if (value.isLeft()) return Either.makeLeft(value.getLeft());
        return func(value.getRight());
    }
}

藉助 bind() 實現的 readCatFromFile() 函數:

function readCatFromFile(path: string): Either<Error, Cat> {
    let handle: Either<Error, FileHandle> = openFile(path);
    let content: Either<Error, string> = Either.bind(handle, readFile);
    return Either.bind(content, deserializeCat);
}

Functor vs Monad

對於 Box<T>,Functor(map())會接收一個 Box<T> 值和一個從 TU 的函數((value: T) => U)作爲參數,將 T 值取出並應用給傳入的函數,最終返回 Box<U>
Monad(bind())接收一個 Box<T> 值和一個從 TBox<U> 的函數((value: T) => Box<U>)作爲參數,將 T 值取出並應用給傳入的函數,最終返回 Box<U>

class Box<T> {
    value: T;

    constructor(value: T) {
        this.value = value
    }
}


namespace Box {
    export function map<T, U>(
        box: Box<T>, func: (value: T) => U): Box<U> {
        return new Box<U>(func(box.value));
    }

    export function bind<T, U>(
        box: Box<T>, func: (value: T) => Box<U>): Box<U> {
        return func(box.value);
    }
}


function stringify(value: number): string {
    return value.toString();
}

const s: Box<string> = Box.map(new Box(42), stringify);
console.log(s)
// => Box { value: '42' }


function boxify(value: number): Box<string> {
    return new Box(value.toString());
}

const b: Box<string> = Box.bind(new Box(42), boxify);
console.log(b)
// => Box { value: '42' }

Monad 定義

Monad 表示對於泛型 H<T>,我們有一個 unit() 函數能夠接收 T 作爲參數,返回類型爲 H<T> 的值;同時還有一個 bind() 函數接收 H<T> 和一個從 TH<U> 的函數作爲參數,返回 H<U>
現實中能夠將 Promise 串聯起來的 then() 方法實際上就等同於 bind(),能夠從值創建 Promise 的 resolve() 方法等同於 unit()

藉助 Monad,函數調用序列可以表示爲一條抽離了數據管理、控制流程或副作用的管道。

參考資料

Programming with Types

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