JS函數柯里化(curry)和函數合成(compose)

2020-07-16 10:05:05
JavaScript 函數語言程式設計有兩種最基本的運算:compose(函數合成)和 curry(柯里化)。

函數合成

【問題提出】

在 JS 函數語言程式設計中,經常見到如下表示式運算。
a(b(c(x)));
這是“包菜式”多層函數呼叫,但不是很優雅。為了解決函數多層呼叫的巢狀問題,我們需要用到函數合成。其語法格式如下:
var f = compose(a, b, c);  //合成函數
f(x);
例如:
var compose = function (f, g) {
    return function (x) {
        return f(g(x));
    };
};
var add = function (x) { return x + 1; }  //加法運算
var mul = function (x) { return x * 5; }  //乘法運算
compose(mul, add) (2);  //合併加法運算和乘法運算,返回15
在上面程式碼中,compose 函數的作用就是組合函數,將函數串聯起來執行,一個函數的輸出結果是另一個函數的輸入引數,一旦第 1 個函數開始執行,就會像多米諾骨牌一樣推導執行了。

使用 compose 要注意以下 3 點:
  • compose 的引數是函數,返回的也是一個函數。
  • 除了初始函數(最右側的一個)外,其他函數的接收引數都是一個函數的返回值,所以初始函數的引數可以是多元的,而其他函數的接收值是一元的。
  • compose 函數可以接收任意的引數,所有的引數都是函數,且執行方向為自右向左。初始函數一定要放到引數的最右側。

下面來完善 compose 實現,實現無限函數合成。

既然函數以多米諾骨牌式執行,那麼可以使用遞迴或疊代,在函數體內不斷地執行 arguments 中的函數,將上一個函數的執行結果作為下一個執行函數的輸入引數。
//函數合成,從右到左合成函數
var compose = function () {
    var _arguments = arguments;  //快取外層函數
    var length = _arguments.length;  //快取長度
    var index = length;  //定義游標變數
    //檢測函數,如果存在非函數引數,則丟擲異常
    while (index --) {
        if (typeof _arguments[index] !== 'function') {
            throw new TypeError('引數必須為函數!');
        }
    }
    return function () {
        var index = length - 1;  //定位到最後一個引數下標
        //如果存在兩個及以上引數,則呼叫最後一個引數函數,並傳入內層函數;否則直接返回第 1 個引數函數。
        var result = length ? _arguments[index].apply(this, arguments) : arguments[0];
        //疊代引數函數
        while (index -- ) {
            //把右側函數的執行結果作為引數傳給左側引數函數,並呼叫。
            result = _arguments[index].call(this, result);
        }
        return result;  //返回最左側引數函數的執行結果
    }
}
//反向函數合成,即從左到右合成函數
var composeLeft = function () {
    return compose.apply(null, [].reverse.call(arguments));
}
在上面實現程式碼中,compose 實現是從右到左進行合成,也提供了從左到右的合成,即 composeLeft,同時在 compose 體內新增了一層函數的校驗,允許傳遞一個或多個引數。
var add = function (x) { return x + 5; }  //加法允許
var mul= function (x) { return x * 5; }  //乘法運算
var sub= function (x) { return x - 5; }  //減法運算
var div = function (x) { return x / 5; }  //除法運算
var fn = compose(add, mul, sub, div);
console.log(fn(50));  //返回30
var fn = compose(add, compose(mul, sub, div));
console.log(fn(50));  //返回30
var fn = compose(compose(add, mul), sub, div);
console.log(fn(50));  //返回30
上面幾種組合方式都可以,最後都返回 30。注意,排列順序要保持一致。

函數柯里化

【問題提出】

JS 函數合成是把多個單一引數函數合成一個多引數函數的運算。例如,a(x) 和 b(x) 組合為 a(b(x)),則合稱為 f(a,b,x)。注意,這裡的 a(x) 和 b(x) 都只能接收一個引數。如果接收多個引數,如 a(x,y) 和 b(a,b,c),那麼函數合成就比較麻煩。

這時就要用到函數柯里化。所謂柯里化,就是把一個多引數的函數轉化為單一引數函數。有了柯里化運算之後,我們就能做到所有函數只接收一個引數。

【設計思路】

先用傳遞給函數的一部分引數來呼叫它,讓它返回一個函數,然後再去處理剩下的引數。也就是說,把多引數的函數分解為多步操作的函數,以實現每次呼叫函數時,僅需要更少或單個引數。例如,下面是一個簡單的求和函數 add()。
var add = function (x,y) {
    return x + y;
}
每次調動 add(),需要同時傳入兩個引數。如果希望每次僅傳入一個引數,可以這樣進行柯里化。
var add = function (x) {  //柯里化
    return function (y) {
        return x + y;
    }
}
console.log(add(2) (6));  //8,連續呼叫
var add1 = add(200);
console.log(add1(2));  //202,分步呼叫
函數 add 接收一個引數,並返回一個函數,這個返回的函數可以再接收一個引數,並返回兩個引數之和。從某種意義上講,這是一種對引數的“快取”,是一種非常高效的函數式運算方法。柯里化在 DOM 的回撥中非常有用。

【實現程式碼】

設想 curry 可以接收一個函數,即原始函數,返回的也是一個函數,即柯里化函數。返回這個柯里化函數在執行過程中會不斷地返回一個儲存了傳入引數的函數,直到觸發了原始函數執行的條件。例如,設計一個 add() 函數,計算兩個引數之和。
var add = function (x,y) {
    return x + y;
}
柯里化函數:
var curryAdd = curry(add);
這個 add 需要兩個引數,但是執行 curryAdd 時,可以傳入更少的引數。當傳入的引數少於 add 需要的引數時,add 函數並不會執行,curryAdd 就會將這個引數記錄下來,並且返回另外一個函數,這個函數可以繼續執行傳入引數。如果傳入引數的總數等於 add 所需引數的總數,則執行原始引數,返回想要的結果。如果沒有引數限制,最後根據空的小括號作為執行原始引數的條件,返回運算結果。

curry 實現的封裝程式碼如下:
//柯里化函數
function curry (fn) {
    var _argLen = fn.length;  //記錄原始函數的形參個數
    var _args = [].slice.call(arguments, 1);  //把傳入的第2個及以後引數轉換為陣列
    function wrap () {  //curry函數
        //把當前引數轉換為陣列,與前面引數進行合併
        _args = _args.concat([].slice.call(arguments));
        function act () {  //引數處理常式
            //把當前引數轉換為陣列,與前面引數進行合併
            _args = _args.concat([].slice.call(arguments));
            //如果傳入引數總和大於等於原始引數的個數,觸發執行條件
            if ((_argLen == 0 && arguments.length == 0) ||
                (_argLen > 0 && _args.length >= _argLen)) {
                //執行原始函數,並把每次傳入引數傳入進去,返回執行結果,停止curry
                return fn.apply(null, _args);
            }
            return arguments.callee;
        }
        //如果傳入引數大於等於原始函數的引數個數,即觸發了執行條件
        if ((_argLen == 0 && arguments.length == 0) ||
            (_argLen > 0 && _args.length >= _argLen)) {
            //執行原始函數,並把每次傳入引數傳入進去,返回執行結果,停止curry
            return fn.apply(null, _args);
        }
        act.toString = function () {  //定義處理常式的字串表示為原始函數的字串表示
            return fn.toString();
        }
        return act;  //返回處理常式
    }
    return wrap;  //返回curry函數
}

【應用程式碼】

1) 應用函數無形參限制
設計求和函數,沒有形參限制柯里化函數將根據小括號作為最後呼叫原始函數的條件。
//求和函數,引數不限
var add = function () {
    //疊代所有引數值,返回最後彙總的值
    return [].slice.call(arguments).reduce(function (a,b) {
        //如果元素的值為數值,則參與求和運算,否則設定為0,跳過非數位的值
        return (typeof a == "number" ? a : 0) + (typeof b =="number" ? b : 0);
    })
}
//柯里化函數
var curried = curry(add);
console.log(curried(1) (2) (3) ());  //6
var curried = curry(add);
console.log(curried(1,2,3) (4) ());  //10
var curried = curry(add, 1);
console.log(curried(1,2) (3) (3) ());  //10
var curried = curry(add, 1, 5);
console.log(curried(1,2,3,4) (5) ());  //21

2) 應用函數有形參限制
設計求和函數,返回 3 個引數之和。
var add = function (a,b,c) {  //求和函數,3個引數之和
    return a + b + c;
}
//柯里化函數
var curried = curry(add, 2);
console.log(curried(1) (2));  //5
var curried = curry(add, 2, 1);
console.log(curried(2));  //5
var curried = curry(add);
console.log(curried (1) (2) (6));  //9
var curried = curry(add);
console.log(curried(1,2,6));  //9

curry 函數的設計不是固定的,可以根據具體應用場景靈活客製化。curry 主要有 3 個作用:快取函數、暫緩函數執行、分解執行任務。