高階函式應用 —— 柯里化與反柯里化

PandaShen發表於2018-07-25

高階函式應用 —— 柯里化與反柯里化


原文出自:https://www.pandashen.com


前言

在 JavaScript 中,柯里化和反柯里化是高階函式的一種應用,在這之前我們應該清楚什麼是高階函式,通俗的說,函式可以作為引數傳遞到函式中,這個作為引數的函式叫回撥函式,而擁有這個引數的函式就是高階函式,回撥函式在高階函式中呼叫並傳遞相應的引數,在高階函式執行時,由於回撥函式的內部邏輯不同,高階函式的執行結果也不同,非常靈活,也被叫做函數語言程式設計。


柯里化

在 JavaScript 中,函式柯里化是函數語言程式設計的重要思想,也是高階函式中一個重要的應用,其含義是給函式分步傳遞引數,每次傳遞部分引數,並返回一個更具體的函式接收剩下的引數,這中間可巢狀多層這樣的接收部分引數的函式,直至返回最後結果。

1、最基本的柯里化拆分

// 原函式
function add(a, b, c) {
    return a + b + c;
}

// 柯里化函式
function addCurrying(a) {
    return function (b) {
        return function (c) {
            return a + b + c;
        }
    }
}

// 呼叫原函式
add(1, 2, 3); // 6

// 呼叫柯里化函式
addCurrying(1)(2)(3) // 6
複製程式碼

被柯里化的函式 addCurrying 每次的返回值都為一個函式,並使用下一個引數作為形參,直到三個引數都被傳入後,返回的最後一個函式內部執行求和操作,其實是充分的利用了閉包的特性來實現的。

2、柯里化通用式

上面的柯里化函式沒涉及到高階函式,也不具備通用性,無法轉換形參個數任意或未知的函式,我們接下來封裝一個通用的柯里化轉換函式,可以將任意函式轉換成柯里化。

// ES5 的實現
function currying(func, args) {
    // 形參個數
    var arity = func.length;
    // 上一次傳入的引數
    var args = args || [];

    return function () {
        // 將引數轉化為陣列
        var _args = [].slice.call(arguments);

        // 將上次的引數與當前引數進行組合並修正傳參順序
        Array.prototype.unshift.apply(_args, args);

        // 如果引數不夠,返回閉包函式繼續收集引數
        if(_args.length < arity) {
            return currying.call(null, func, _args);
        }

        // 引數夠了則直接執行被轉化的函式
        return func.apply(null, _args);
    }
}
複製程式碼

上面主要使用的是 ES5 的語法來實現,大量的使用了 callapply,下面我們通過 ES6 的方式實現功能完全相同的柯里化轉換通用式。

// ES6 的實現
function currying(func, args = []) {
    let arity = func.length;

    return function (..._args) {
        _args.unshift(...args);

        if(_args.length < arity) {
            return currying.call(null, func, _args);
        }

        return func(..._args);
    }
}
複製程式碼

函式 currying 算是比較高階的轉換柯里化的通用式,可以隨意拆分引數,假設一個被轉換的函式有多個形參,我們可以在任意環節傳入任意個數的引數進行拆分,舉一個例子,假如 5 個引數,第一次可以傳入 2 個,第二次可以傳入 1 個, 第三次可以傳入剩下的,也有其他的多種傳參和拆分方案,因為在 currying 內部收集引數的同時按照被轉換函式的形參順序進行了更正。

柯里化的一個很大的好處是可以幫助我們基於一個被轉換函式,通過對引數的拆分實現不同功能的函式,如下面的例子。

// 被轉換函式,用於檢測傳入的字串是否符合正規表示式
function checkFun(reg, str) {
    return reg.test(str);
}

// 轉換柯里化
let check = currying(checkFun);

// 產生新的功能函式
let checkPhone = check(/^1[34578]\d{9}$/);
let checkEmail = check(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/);
複製程式碼

上面的例子根據一個被轉換的函式通過轉換變成柯里化函式,並用 _check 變數接收,以後每次呼叫 _check 傳遞不同的正則就會產生一個檢測不同型別字串的功能函式。

這種使用方式同樣適用於被轉換函式是高階函式的情況,比如下面的例子。

// 被轉換函式,按照傳入的回撥函式對傳入的陣列進行對映
function mapFun(func, array) {
    return array.map(func);
}

// 轉換柯里化
let getNewArray = currying(mapFun);

// 產生新的功能函式
let createPercentArr = getNewArray(item => `${item * 100}%`);
let createDoubleArr = getNewArray(item => item * 2);

// 使用新的功能函式
let arr = [1, 2, 3, 4, 5];
let percentArr = createPercentArr(arr); // ['100%', '200%', '300%', '400%', '500%',]
let doubleArr = createDoubleArr(arr); // [2, 4, 6, 8, 10]
複製程式碼

3、柯里化與 bind

bind 方法是經常使用的一個方法,它的作用是幫我們將呼叫 bind 函式內部的上下文物件 this 替換成我們傳遞的第一個引數,並將後面其他的引數作為呼叫 bind 函式的引數。

// bind 方法的模擬
Object.prototype.bind = function (context) {
    var self = this;
    var args = [].slice.call(arguments, 1);

    return function () {
        return self.apply(context, args);
    }
}
複製程式碼

通過上面程式碼可以看出,其實 bind 方法就是一個柯里化轉換函式,將呼叫 bind 方法的函式進行轉換,即通過閉包返回一個柯里化函式,執行該柯里化函式的時候,借用 apply 將呼叫 bind 的函式的執行上下文轉換成了 context 並執行,只是這個轉換函式沒有那麼複雜,沒有進行引數拆分,而是函式在呼叫的時候傳入了所有的引數。


反柯里化

反柯里化的思想與柯里化正好相反,如果說柯里化的過程是將函式拆分成功能更具體化的函式,那反柯里化的作用則在於擴大函式的適用性,使本來作為特定物件所擁有的功能函式可以被任意物件所使用。

1、反柯里化通用式

反柯里化通用式的引數為一個希望可以被其他物件呼叫的方法或函式,通過呼叫通用式返回一個函式,這個函式的第一個引數為要執行方法的物件,後面的引數為執行這個方法時需要傳遞的引數。

// ES5 的實現
function uncurring(fn) {
    return function () {
        // 取出要執行 fn 方法的物件,同時從 arguments 中刪除
        var obj = [].shift.call(arguments);
        return fn.apply(obj, arguments);
    }
}
複製程式碼
// ES6 的實現
function uncurring(fn) {
    return function (...args) {
        return fn.call(...args);
    }
}
複製程式碼

下面我們通過一個例子來感受一下反柯里化的應用。

// 建構函式 F
function F() {}

// 拼接屬性值的方法
F.prototype.concatProps = function () {
    let args = Array.from(arguments);
    return args.reduce((prev, next) => `${this[prev]}&${this[next]}`);
}

// 使用 concatProps 的物件
let obj = {
    name: "Panda",
    age: 16
};

// 使用反柯里化進行轉化
let concatProps = uncurring(F.prototype.concatProps);

concatProps(obj, "name", "age"); // Panda&16
複製程式碼

反柯里化還有另外一個應用,用來代替直接使用 callapply,比如檢測資料型別的 Object.prototype.toString 等方法,以往我們使用時是在這個方法後面直接呼叫 call 更改上下文並傳參,如果專案中多處需要對不同的資料型別進行驗證是很麻的,常規的解決方案是封裝成一個檢測資料型別的模組。

// 常規方案
function checkType(val) {
    return Object.prototype.toString.call(val);
}
複製程式碼

如果需要這樣封裝的功能很多就麻煩了,程式碼量也會隨之增大,其實我們也可以使用另一種解決方案,就是利用反柯里化通用式將這個函式轉換並將返回的函式用變數接收,這樣我們只需要封裝一個 uncurring 通用式就可以了。

// 利用反柯里化建立檢測資料型別的函式
let checkType = uncurring(Object.prototype.toString);

checkType(1); // [object Number]
checkType("hello"); // [object String]
checkType(true); // [object Boolean]
複製程式碼

2、通過函式呼叫生成反柯里化函式

在 JavaScript 我們經常使用物件導向的程式設計方式,在兩個類或建構函式之間建立聯絡實現繼承,如果我們對繼承的需求僅僅是希望一個建構函式的例項能夠使用另一個建構函式原型上的方法,那進行繁瑣的繼承很浪費,簡單的繼承父子類的關係又不那麼的優雅,還不如之間不存在聯絡。

Function.prototype.uncurring = function () {
    var self = this;
    return function () {
        return Function.prototype.call.apply(self, arguments);
    }
}
複製程式碼

之前的問題通過上面給函式擴充套件的 uncurring 方法完全得到了解決,比如下面的例子。

// 建構函式
function F() {}

F.prototype.sayHi = function () {
    return "I'm " + this.name + ", " + this.age + " years old.";
}

// 希望 sayHi 方法被任何物件使用
sayHi = F.prototype.sayHi.uncurring();

sayHi({ name: "Panda", age: 20}); // I'm Panda, 20 years old.
複製程式碼

Function 的原型物件上擴充套件的 uncurring 中,難點是理解 Function.prototype.call.apply,我們知道在 call 的原始碼邏輯中 this 指的是呼叫它的函式,在 call 內部用第一個引數替換了這個函式中的 this,其餘作為形參執行了函式。

而在 Function.prototype.call.applyapply 的第一個引數更換了 call 中的 this,這個用於更換 this 的就是例子中呼叫 uncurring 的方法 F.prototype.sayHi,所以等同於 F.prototype.sayHi.callarguments 內的引數會傳入 call 中,而 arguments 的第一項正是用於修改 F.prototype.sayHithis 的物件。


總結

看到這裡你應該對柯里化和反柯里化有了一個初步的認識了,但要熟練的運用在開發中,還需要我們更深入的去了解它們內在的含義。


相關文章