打造屬於自己的underscore系列(五)- 偏函式和函式柯里化

不做祖國的韭菜發表於2019-02-11

這一節的內容,主要針對javascript函數語言程式設計的兩個重要概念,偏函式(partial application) 和函式柯里化(curry)進行介紹。著重講解underscore中對於偏函式應用的實現。

四, 偏函式和函式柯里化

4.1 基本概念理解

javascript的函數語言程式設計有兩個重要的概念,偏函式(partial application)和函式柯里化(curry)。理解這兩個概念之前,我們需要先知曉什麼是函數語言程式設計? 函數語言程式設計是一種程式設計風格,它可以將函式作為引數傳遞,並返回沒有副作用的函式。而什麼是偏函式應用(partial application), 通俗點理解,固定一個函式的一個或者多個引數,也就是將一個 n 元函式轉換成一個 n - x 元函式;函式柯里化(curry)的理解,可以概括為將一個多引數函式轉換成多個單引數函式,也就是將一個 n 元函式轉換成 n 個一元函式。舉兩個簡單的例子方便大家理解並對比其本質區別。

// 偏函式
function add(a, b, c) {
    return a + b + c
}
var resultAdd = partial(add, 1); // partial 為偏函式原理實現
resultAdd(2, 3) // 將多引數的函式轉化為接收剩餘引數(n-1)的函式

// 函式柯里化
function add (a, b, c) {
    return a + b + c
}
var resultAdd = curry(add) //  curry 為柯里化實現
resultAdd(1)(2)(3)  // 將多引數的函式轉化成接受單一引數的函式
複製程式碼

在underscore中只有對偏函式應用的實現,並沒有函式柯里化的實現,因此本文只對underscore偏函式的實現做詳細探討,而柯里化實現只會在文末簡單提及。(tips: lodash 有針對curry的函式實現)

4.2 rest引數

偏函式和柯里化的實現依賴於reset引數的概念,這是一個ES6的概念,rest引數(...rest)用於獲取函式的多餘引數,比如;

function add (a, ...values) { console.log(values) } // [2,4,6]
add(1, 2, 4, 6) //  獲取除了第一個之後的剩餘引數並以陣列的形式返回。
複製程式碼

underscore中的restArguments方法,實現了與ES6中rest引數語法相似的功能,restArguments函式傳遞兩個引數,function 和起始reset的位置,返回一個function的版本,該版本函式在呼叫時會接收來自起始rest位置後的所有引數,並收集到一個陣列中。如果起始rest位置沒有傳遞,則根據function本身的引數個數來確定。由於描述比較晦澀難懂,我們可以舉一個具體的例子

var result = function (a, b, c) {
    console.log(a) // 3
    console.log(b) // 15
    console.log(c) // [2, 3, 2]
    return 'haha'
}
var raceResults = _.restArguments(result);
raceResults(3,15,2,3,2)
複製程式碼

result函式從接收三個引數,經過restArguments方法轉換後,將接收的多餘引數以陣列的方式儲存。當傳遞起始reset位置即startIndex時,例項如下:

var result = function (a, b, c) {
    console.log(a) // 3
    console.log(b) // [15, 2, 3, 2]
    console.log(c) // undefined
    return ''
}
var raceResults = _.restArguments(result, 1);
raceResults(3,15,2,3,2)
複製程式碼

startIndex 會指定原函式在何處將餘下的引數轉換成rest,例子中會在第一個引數之後將引數轉成rest陣列形式。因此有了這兩種情景,我們可以實現一個簡化版的restArguments方法,具體的思路可以參考程式碼註釋

/**
 * 模仿es6 reset引數
 * fn  函式
 * [startIndex]: 接收引數的起始位置,如未傳遞,則為fn本身引數個數
 */
_.restArguments = function (fn, startIndex) {
    return function () {
        var l = startIndex == null ? fn.length - 1 : startIndex; // 如果沒有傳遞startIndex,則rest陣列的起始位置為引數倒數第二個
        l = l - fn.length < 0 ? l : 0; // 如果startIndex有傳遞值,但該值超過函式的引數個數,則預設將rest陣列的起始位置設為第一個
        var arr = []
        var args = slice.call(arguments);
        for (var i = 0; i < l; i++) {
            arr.push(args[i]) // arr 儲存startIndex前的引數
        }
        var restArgs = slice.call(arguments, l)
        arr.push(restArgs) // 將startIndex後的引數以陣列的形式插入arr中,eg: arr = [1,3,4,[2,5,6]]
        return fn.apply(this, arr) //  呼叫時,fn引數引數形式已經轉換成 1,3,4,[2,5,6]
    }
}
複製程式碼

restArgument實現rest引數的形式,本質上是改變引數的傳遞方式,函式呼叫時會將指定位置後的引數轉化成陣列形式的引數。

4.3 不繫結this指向的偏函式應用

在4.1的偏函式概念理解中,我們已經瞭解了偏函式的概念和使用形式,即將多引數的函式轉化為接收剩餘引數(n-1)的函式。在underscore中_.partial方法提供了對偏函式的實現。

// 使用
_.partial(function, *arguments)
// 舉例
var subtract = function(a, b) { return b - a; };
sub5 = _.partial(subtract, 5);
sub5(20); // 15
// 可以傳遞_ 給arguments列表來指定一個不預先填充,但在呼叫時提供的引數
subFrom20 = _.partial(subtract, _, 5);
subFrom20(20); // -15
複製程式碼

有了restArguments的基礎,實現一個partial函式便水到渠成。呼叫partial時,函式經過restArguments這層包裝後,函式的剩餘引數直接轉成rest陣列的形式,方便後續邏輯處理。

/**
 * 偏函式
 * 不指定執行上下文
 */
_.partial = _.restArguments(function (fn, reset) { //  將後續引數轉化成rest陣列形式
    return function () {
        var position = 0
        var placeholder = _.partial.placeholder; //  佔位符,預先不填充,呼叫時填充
        var length = reset.length;
        var args = Array(length);
        for (var i = 0; i < length; i++) {
            args[i] = reset[i] === placeholder ? arguments[position++] : reset[i]; // 預先儲存partial封裝時傳遞的引數,當遇到佔位符時,用partial處理後函式呼叫傳遞的引數代替。
        }
        while (position < arguments.length) {
            args.push(arguments[position++]) // 將partial處理後函式呼叫的引數和原儲存引數合併。真正呼叫函式時傳遞執行。
        }
        return fn.apply(this, args)
    }
})

_.partial.placeholder = _;
複製程式碼

偏函式的思想,本質上可以這樣理解,將引數儲存起來,在呼叫函式時和呼叫傳遞引數合併,作為真正執行函式時的引數。

4.4 繫結this指向的偏函式應用

_.partial方法雖然實現了偏函式,但是當方法的呼叫需要結合上下文時,patial方法無法指定上下文,例如

var obj = {
    age: 1111,
    methods: function (name, time) {
        return name + '' + this.age + time 
    }
}

var sresult = _.partial(obj.methods, 3);
console.log(sresult(5)) // 3undefined5
複製程式碼

從偏函式的定義我們知道,原生javascript中,Function.prototype.bind()已經可以滿足偏函式應用了

function add3(a, b, c) { return a+b+c; }  
add3(2,4,8);  // 14

var add6 = add3.bind(this, 2, 4);  
add6(8);  // 14  
複製程式碼

而在underscore同樣封裝了這樣的方法,_.bind(function, object, *arguments) , 從bind函式的定義中可以知道,該方法將繫結函式 function 到物件 object 上, 也就是無論何時呼叫函式, 函式裡的 this 都指向這個 object,並且可以填充函式所需要的引數。它是一個能結合上下文的偏函式應用,因此只需要修改partial的呼叫方式即可實現bind方法。

/**
 * bind
 * 偏函式指定this
 */
_.bind = _.restArguments(function (fn, obj, reset) {
    return function () {
        var position = 0
        var placeholder = _.partial.placeholder;
        var length = reset.length;
        var args = Array(length);
        for (var i = 0; i < length; i++) {
            args[i] = reset[i] === placeholder ? arguments[position++] : reset[i]
        }
        while (position < arguments.length) {
            args.push(arguments[position++])
        }
        return fn.apply(obj, args) // 指定obj為執行上下文
    }
})
複製程式碼
4.5 其他版本偏函式

至此,underscore中關於偏函式的實現已經介紹完畢,其設計思想是先將引數儲存起來,在呼叫函式時和呼叫傳遞引數合併,作為真正執行函式時的引數執行函式。因此拋離underscore,我們可以用arguments和es6的rest引數的方式來實現偏函式,下面提供兩個簡易版本。

// arguments版本
function partial(fn) {
    var args = [].slice.call(arguments, 1);
    return function() {
        return fn.apply(this, args.concat([].slice.call(arguments)))
    }
}
// es6 rest版本
function partial(fn, ...rest) {
    return (...args) => {
     return fn(...rest, ...args)   
    }
}
複製程式碼
4.6 函式柯里化

前文提到,underscore並沒有關於函式柯里化的實現,只在它的相似庫lodash才有對柯里化的實現。柯里化的思想是將一個多引數的函式拆分為接收單個引數的函式,接收單個引數的函式會返回另一個函式,直到接收完所有引數後才返回計算結果。因此,實現思路可以參考以下兩種,es6版本和前者的實現思路相同。

// 完整版柯里化 ES3
function curry(fn) {
    if(fn.length < 2) return fn; // 當fn的引數只有一個或者更少時, 直接返回該函式並不需要柯里化。
    const generate = function(args, length) {
        return !length ? fn.apply(this, args) : function(arg) {
            return generate(args.concat(arg), length -1) // 迴圈遞迴呼叫,直到接收完所有引數(與函式引數個數一致), 將所有引數傳遞給fn進行呼叫。
        }
    }
    return generate([], fn.length)
}
// 完整版柯里化es6
function curryEs6(fn) {
    if(fn.length < 2) return fn
    const generate = (args, length) => !length ? fn(...args) : arg => generate([...args, arg], length - 1);
    return generate([], fn.length)
}
複製程式碼

柯里化的實現思路多樣,且衍生變種內容較多,這裡不一一闡述,有時間再另寫一篇深入探討。而關於偏函式的應用,會有專門一節來介紹underscore中關於偏函式的應用,主要應用於延遲過程處理等。




相關文章