Ramda.js中的柯里化實現

Spring不想說話發表於2019-03-02

Tips:

這不是一個講解柯里化原理與應用的文章,只是通過JavaScript來實現柯里化,所以本文不會分析柯里化在JavaScript中應用的優劣,也不會講解柯里化的由來和他本身存在的意義。掘金上已經有很多好的文章來講解這些了。因為是一邊寫程式碼來實現柯里化一邊來寫文章,所以文章讀起來可能不會有很強的連貫性。如果有不對的地方,歡迎指出,擱置爭議,共同開發,相互學習,共同進步~

Why?

通常柯里化的實現是這樣的:

 function curry (fn) {
    return function f() {
        const args = [].slice.call(arguments);
        if(args.length < fn.length) {
            return function() {
                return f.apply(this, args.concat([].slice.call(arguments)))
            }
        } else {
          return  fn.apply(this, args);
        }
    } 
}
複製程式碼

這個柯里化的實現有兩個問題:

  • 呼叫柯里化的函式後無法確定函式元數。
  • 傳入引數位置必須和函式接受的引數位置保持一致

現在我們解決第一個問題:獲取不到柯里化後函式的引數。

我們知道函式的引數是可以通過length屬性來獲取的,所以我們需要一個輔助函式來確定函式引數的函式,這裡是arity的實現:

function arity (n, fn) {
        switch(n){
            case 0:
                return function() { return fn.apply(this, arguments)};
            case 1:
                return function(a0) { return fn.apply(this, arguments)};
            case 2:
                return function(a0, a1) { return fn.apply(this,arguments)};
            case 3:
                return function(a0, a1, a2) { return fn.apply(this, arguments)};
            case 4:
                return function(a0, a1, a2, a3) { return fn.apply(this, arguments)};
            case 5:
                return function(a0, a1, a2, a3, a4) { return fn.apply(this, arguments)};
            case 6:
                return function(a0, a1, a2, a3, a4, a5) { return fn.apply(this, arguments)};
            case 7:
                return function(a0, a1, a2, a3, a4, a5, a6) { return fn.apply(this, arguments)};
            case 8:
                return function(a0, a1, a2, a3, a4, a5, a6, a7) { return fn.apply(this, arguments)};
            case 9:
            return function(a0, a1, a2, a3, a4, a5, a6, a7, a8) { return fn.apply(this, arguments)};
            case 10:
                return function(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9) { return fn.apply(this, arguments)};
            default: 
            throw new Error('First argument to arity must be a non-negative integer no greater than ten');
        }
    }
複製程式碼

這裡的arity只做了一件事,那就是根據包裹一個函式,返回一個確定引數的函式。一般來說,函式的複雜度是和他自身的引數成正比的,函式接收的函式越多,那麼函式的複雜度就越高,雖然JavaScript中沒有明確規定的傳入引數的個數(好像是225個?),但是我們這裡限制如果一個函式的引數超過是個那麼就丟擲錯誤。注意:arity函式也是ramda.js的內部實現。 有了包裹函式的arity函式,我們繼續,來實現確定返回引數個數的柯里化版本:

function curry (length, recived, fn) {
        return function() {
            var args = [].slice.call(arguments);
            var combined = recived.concat(args);
            
            if(combined.length < length ) {
                return arity(length - combined.length, curry(length, combined, fn));
                
            } else {
                return fn.apply(this, combined);
            }
        }
    }
複製程式碼

這裡的curry函式接收三個引數,length:即函式引數的個數,recived:一個儲存傳入引數的陣列,初始化為空陣列,fn:柯里化的函式。呼叫curry後返回一個函式,通過閉包將返回的函式的引數和recived中的函式合併。如果接收的引數個數小於柯里化函式的引數個數,那麼通過arity函式遞迴呼叫curry函式來收集剩餘引數。這是一個生產->消費的過成。現在我們嘗試呼叫一下改進後的curry:

    const a = (x, y, z) => x+y+z;
    const b = curry(3, [], a)(1);
    console.log(b.length) //=> 2
    
    const c = curry(3, [], a)(1, 2);
    console.log(c.length) //=> 1
    
    const d = curry(3, [], a)(1)(2);
    console.log(d) //=> 1
    
複製程式碼

很完美!現在解決第二個問題,傳入引數位置必須保持一致。這裡我們需要一個佔位符,佔位符的作用就是尚待指定的引數,如果當前的引數是佔位符,那表明應該忽略傳入的引數。我們想要的結果是這樣的

g(1, 2, 3)
g(_, 2, 3)(1)
g(_, _, 3)(1)(2)
複製程式碼

以上的呼叫是等價的,示例出處Ramda官方文件。現在我們來實現佔位符

const _ = { '@@function/placeholder' : true};
const _isPlaceholder = function (x) { 
        return  !!x[ '@@function/placeholder'] 
}
複製程式碼

現在實現加入佔位符的curry函式,這裡我們需要一個陣列來存放初始化傳入的引數和經過柯里化函式呼叫時傳入的引數,這是一個引數陣列合並的過程。假設我們有一個函式:

const f = (x, y, z) => x+y+z

呼叫const g = curry(3, [], f) 初始化時傳入了一個空陣列,得到一個包裹函式,現在我們宣告一個名為combined的空陣列,來儲存呼叫這個包裹函式傳入的引數和初始化函式時傳入的引數的陣列。以下是Ramda.js中curry的實現:

//length: 柯里化函式引數的個數
//recived: 初始化接收的引數陣列,
//fn : 柯里化的函式
function _curryN(length, recived, fn) {
    return function() {
        //存放每次呼叫函式引數的陣列
        var combined = [];
        var args = [].slice.call(arguments);
        var argsIdx = 0;
        //用於檢查引數是否全部傳入
        var offset = length;
        
        /* 
        這裡同時迭代recived和arguments。
        我們要迴圈取出每一次curryN初始化接收到的引數和呼叫函式時傳入的引數儲存在combined中,
        這裡用一個額外的變數argsIdx用於迭代arguments的。
        */
        while(combined.length < recived.length || argsIdx < args.length) {
            var result;
            //首先迭代recived,取出不是佔位符的引數仿入combined中
            if(combined.length < recived.length && (!_isPlaceholder(recived[combined.length]) || argsIdx >= args.length)) {
                result = recived[combined.length];
            } else {
                //如果recived已經迭代完了那麼將arguments放入combined中
                result = args[argsIdx];
                argsIdx++;
            }
            
            combined[combined.length] = result;
            //如果當前引數不是佔位符,則長度減1
            if(!_isPlaceholder(result)) offset -= 1;
            console.log(combined)
        }
        
        //如果傳入引數滿足fn引數個數,則直接呼叫fn,否則遞迴呼叫curry函式,反覆過濾掉recived的佔位符
        return offset <= 0 ? fn.apply(this, combined) : _arity(offset, _curryN(length, combined, fn));
    }
}
複製程式碼

現在我們得到一個帶有佔位符功能的柯里化函式,我們試一下:

function say(name, age, like) { console.log(`我叫${name},我${age}歲了, 我喜歡${like}`) };
const msg = _curryN(3, [], say)
msg(_, 20)('大西瓜', _,) ('妹子')        // 我叫大西瓜,我20歲了, 我喜歡妹子
msg(_, _, '瞎bb')(_, '25')('小hb')     // 我叫小hb,我25歲了, 我喜歡瞎bb
msg('小明')(_, _)(22,  '小紅')         // 我叫小明,我22歲了, 我喜歡小紅
複製程式碼

以上就是ramda中_curryN中的實現,至於curry和curryN也是基於_curryN來實現的。一開始看Ramda中curry的實現感覺腦子要炸了,那麼多變數,根本不想去看。然後慢慢的嘗試自己去寫,比如先不實現佔位符,先實現一個確定函式引數個數的curry,把複雜的問題分解成簡單的問題,通過實現簡單的功能來組合成複雜的功能,這也是函數語言程式設計鼓勵的。本文沒有理論講解,通篇的程式碼,幸好還有一點註釋?。第一次寫,也算是記錄自己的一個學習過程,歡迎大家留言討論。

相關文章