Functional JavaScript: 使用 Transducer 提高函數式性能

Functional JavaScript 之 Transducer

1. 什麼是 Transducer

在函數式編程中,Transducer 是一種用於處理數據的高效、可組合不會產生的中間數據函數git

這樣說可能會有些讓人沒法理解,讓咱們用通俗的代碼解釋一遍:github

假設咱們須要找出 100 之內能被 3 整除的全部奇數的平方和(注:爲了更清晰的表示函數的參數與返回值,全部的示例代碼均使用 TypeScript):算法

// 先定義一個輔助函數用於生成指定範圍內的數組
function range(from: number = 0, to: number = 0, skip: number = 1): number[] {
    const result = [];
    for (let i = from; i < to; i += skip) {
        result.push(i);
    }
    return result;
}

const odd = (num: number): boolean => num % 2 !== 0;			// 判斷是否奇數
const pow2 = (num: number): number => Math.pow(num, 2);			// 計算平方
const sum = (cum: number, num: number): number => cum + num;	// 求和
const data: number = range(0, 100).filter(odd).map(pow2).reduce(sum);	// 計算結果
console.log(data);	// 166650
複製代碼

上面是大多數時候的普通寫法,不難發現,生成的數組被遍歷了三次,而且由於咱們只須要 reduce 計算的結果,中間 filter 與 map 函數都會產生無效的中間數據。上述作法在處理大量數據時毋庸置疑會因爲屢次遍歷與大量中間數據的產生,形成嚴重的性能問題,這也是 JavaScript 中的函數式編程被人們所詬病的缺點之一。typescript

所以,在處理大量數據時,人們廣泛更傾向與使用命令式編程的方式:使用 for 循環,但這是必須的嗎?固然不是!Clojure 社區在此前便提出了 Transducer 的概念:https://clojure.org/reference/transducers 。藉助 Transducer 的思想,可以將 map、filter 等一系列處理數據的函數組合成不產生中間數據的高效的函數。編程

如今假設咱們有一個函數 compose 能夠組合其餘函數併產生 Transducers 函數,咱們能夠這樣實現上述算法:數組

const trans = compose(filter(odd), map(pow2), reduce(sum));
const data: number = trans(range(0, 100));
複製代碼

上述 trans 方法只會遍歷一遍數組,同時執行 filter、map 與 reduce 而後直接得出結果。函數式編程

2. Make Functions Composable

不難發現,compose 函數須要對每個傳入的函數進行組合並將其轉換爲 Transducer,這要求傳入的每個函數都是可組合的(Composable)。這須要每個被組合的函數必須在參數與返回值上都具備通用性,但默認的 map 與 filter 並不知足這一要求,所以咱們須要將它們從新封裝一遍使其具備統一的參數和返回值模式。函數

無論是 map、filter 仍是 forEach 都是對集合的遍歷操做,全部的遍歷操做都能用 reduce 實現,所以咱們使用 reduce 封裝出 map 與 filter 使其知足相同的參數與返回值模式。性能

type Reducing<T, U> = (T, U) => T;
type F<T, U> = (T) => U;

const mapReducer = <T, U> (f: F<T, U>) => (result: U[], item: T) => {
    result.push(f(item));
    return result;
};

const filterReducer = <T> (predicate: F<T, boolean>) => (result: T[], item: T) => {
    if (predicate(item)) {
        result.push(item);
    }
    return result;
};

const data: number = range(0, 100)
    .reduce(filterReducer(odd), [])
    .reduce(mapReducer(pow2), [])
    .reduce(sum, 0);
console.log(data);		// 166650
複製代碼

如今,咱們能夠直接使用 mapReducer 與 filterReducer來替代 map 與 filter,它們返回的函數具備相同的參數與返回值模式,咱們把它叫作 Reducing,在 TS 中能夠表示爲type Reducing<T, U> = (T, U) => T;。咱們在此基礎上繼續作一層抽象,讓 Reducing 能夠從外部傳入:ui

const map = <T, U> (f: F<T, U>) => (reducing: Reducing<U[], U>) => (result: U[], item: U) => reducing(result, f(item));

const filter = <T> (predicate: F<T, boolean>) => (reducing: Reducing<T[], T>) => (result: T[], item: T) => predicate(item) ? reducing(result, item) : result;
複製代碼

如今 filter 和 map 都會返回一個高階函數,這個高階函數又可接收一個函數,包括 filter 和 map 返回的函數,這樣它們便成了可組合(composable)了!如今,咱們使用 reduce 把他們組合起來:

const trans: Reducing<number> = filter(odd)(map(pow2)(sum));
const data: number = range(0, 100).reduce(trans, 0);
console.log(data);		// 166650
複製代碼

Well Done! 如今,經過上面一系列的函數,咱們即可簡單地將一系列函數組合成一個高效的函數,從而只需一次遍歷便計算出結果!

3. Compose Function

上述寫法如filter(odd)(map(pow2)(sum))雖然可以實現函數的組合,可是嵌套太深,括號太多,大大下降了代碼的可讀性,所以,咱們實現一個 compose 函數,實現函數的組合;

const compose = (...f: ((...any) => any)[]): Reducing<any> => {
    const [r, ...fs] = [...f].reverse();
    return [...fs].reduce((res, fn) => fn(res), r);
};
複製代碼

compose 函數接受一個 Reducing 函數及一系列的高階函數:(((a, b, …, n) → o), (o → p), …, (x → y), (y → z)) → ((a, b, …, n) → z),compose 函數將參數中第一個函數做爲參數調用第二個函數,而後將返回的函數做爲參數繼續依次調用參數中的函數,最終獲得一個新的 Reducing 函數,咱們把它叫作 Transducer。

如今,用 compose 來組合一系列函數:

const trans: Reducing<number> = compose(filter(odd), map(pow2), sum);
const data: number = range(0, 100).reduce(trans1);
console.log(data);		// 166650
複製代碼

Bingo! 正確得出結果!簡單、清晰又優雅高效。

4. Benchmark

例如:求出小於 1000000 的全部爲 7 的倍數且個位數爲偶數且該數的前一位不能被 4 整除的數字的平方之和:

const even: (number) => boolean = (x) => !odd(x);
const trans: Reducing<number, number> = compose(
    filter(x => x % 7 === 0),
    filter(x => even(x % 10)),
    filter(x => (x - 1) % 4 !== 0),
    map(x => x * x),
    sum
);

console.time("With transducer");
const result1 = range(0, 1000000).reduce(trans, 0);
console.log(result1);
console.timeEnd("With transducer");

console.time("Without transducer");
const result2 = range(0, 1000000)
    .filter(x => x % 7 === 0)
    .filter(x => even(x % 10))
    .filter(x => (x - 1) % 4 !== 0)
    .map(x => x * x)
    .reduce(sum, 0);
console.log(result2);
console.timeEnd("Without transducer");
複製代碼

Benchmark 結果(Node v8.9.1,macOS 10.13.3,i7 2.5 GHz,16GB):

  • With transducer: 50.254ms
  • Without transducer: 89.749ms

在這個例子中,使用 Transducer,經過簡單的函數組合,提高了 44% 的性能!

5. Code

下面是實現 Transducer 的全部代碼,只須要三個函數!

type Reducing<T, U> = (T, U) => T;
type F<T, U> = (T) => U;

const map = <T, U> (f: F<T, U>) => (reducing: Reducing<U[], U>) => (result: U[], item: U) => reducing(result, f(item));

const filter = <T> (predicate: F<T, boolean>) => (reducing: Reducing<T[], T>) => (result: T[], item: T) => predicate(item) ? reducing(result, item) : result;

const compose = (...f: ((...any) => any)[]): Reducing<any, any> => {
    const [r, ...fs] = [...f].reverse();
    return [...fs].reduce((res, fn) => fn(res), r);
};
複製代碼

該系列在 GitHub 上持續更新: 歡迎關注 HuQingyang/Think

相關文章
相關標籤/搜索