高級函數技巧-函數柯里化

我們經常說在Javascript語言中,函數是“一等公民”,它們本質上是十分簡單和過程化的。可以利用函數,進行一些簡單的數據處理,return 結果,或者有一些額外的功能,需要通過使用閉包來實現,最後經常會return 匿名函數。

如果你對函數式編程有一定了解,函數柯里化(function currying)是不可或缺的,利用函數柯里化,可以在開發中非常優雅的處理複雜邏輯。

函數柯里化

柯里化(Currying),維基百科上的解釋是,把接受多個參數的函數轉換成接受一個單一參數的函數
先看一個簡單例子

    // 柯里化
    var foo = function(x) {
        return function(y) {
            return x + y
        }
    }
    
    foo(3)(4)       // 7

    
    // 普通方法
    var add = function(x, y) {
        return x + y;
    }
    
    add(3, 4)       //7 

本來應該一次傳入兩個參數的add函數,柯里化方法,變成每次調用都只用傳入一個參數,調用兩次後,得到最後的結果。

再看看,一道經典的面試題。

編寫一個sum函數,實現如下功能:
 console.log(sum(1)(2)(3)) // 6.

直接套用上面柯里化函數,多加一層return

   function sum(a) {
        return function(b) {
            return function(c) {
                return a + b + c;
            }
        }
    }

當然,柯里化不是爲了解決面試題,它是應函數式編程而生,

如何實現

還是看看上面的經典面試題。
如果想實現 sum(1)(2)(3)(4)(5)...(n)就得嵌套n-1個匿名函數,

   function sum(a) {
        return function(b) {
             ...
            return function(n) {
                
            }
        }
    }
    

看起來並不優雅,如果我們預先知道有多少個參數要傳入,可以利用遞歸方法解決

    var add = function(num1, num2) {
        return num1 + num2;
    }
    
    // 假設 sum 函數調用時,傳入參數都是標準的數字
    function curry(add, n) {
       var count = 0,
           arr = [];
           
       return function reply(arg) {
           arr.push(arg);
           
           if ( ++count >= n) {
               //這裏也可以在外面定義變量,保存每次計算後結果
               return arr.reduce(function(p, c) {
                   return p = add(p, c);
               }, 0) 
           } else {
               return reply;
           }
       }
    }
    var sum = curry(add, 4);
    
    sum(4)(3)(2)(1)  // 10

如果調用次數多於約定數量,sum 就會報錯,我們就可以設計成類似這樣

sum(1)(2)(3)(4)(); // 最後傳入空參數,標識調用結束,

只需要簡單修改下curry 函數

function curry(add) {
       var arr = [];
       
       return function reply() {
         var arg = Array.prototype.slice.call(arguments);
         arr = arr.concat(arg);
         
          if (arg.length === 0) { // 遞歸結束條件,修改爲 傳入空參數
              return arr.reduce(function(p, c) {
                  return p = add(p, c);
              }, 0)
          } else {
              return reply;
          }
      }
    }
  
  console.log(sum(4)(3)(2)(1)(5)())   // 15

簡潔版實現

上面針對具體問題,引入柯里化方法解答,回到如何實現創建柯里化函數的通用方法。
同樣先看簡單版本的方法,以add方法爲例,代碼來自《JavaScript高級程序設計》

 function curry(fn) {
    var args = Array.prototype.slice.call(arguments, 1);
    return function() {
        var innerArgs = Array.prototype.slice.call(arguments);
        var finalArgs = args.concat(innerArgs);
        return fn.apply(null, finalArgs);
    };
}

function add(num1, num2) {
    return num1 + num2;
}
var curriedAdd = curry(add, 5);

var curriedAdd2 = curry(add, 5, 12);

alert(curriedAdd(3))    // 8
alert(curriedAdd2())    // 17

加強版實現

上面add函數,可以換成任何其他函數,經過curry函數處理,都可以轉成柯里化函數。
這裏在調用curry初始化時,就傳入了一個參數,而且返回的函數 curriedAdd , curriedAdd2也沒有被柯里化。要想實現更加通用的方法,在柯里化函數真正調用時,再傳參數,

function curry(fn) {
     ...
 }

function add(num1, num2) {
    return num1 + num2;
}

var curriedAdd = curry(add);

curriedAdd(3)(4) // 7

每次調用curry返回的函數,也被柯里化,可以繼續傳入一個或多個參數進行調用,

跟上面sum(1)(2)(3)(4) 非常類似,利用遞歸就可以實現。 關鍵是遞歸的出口,這裏不能是傳入一個空參數的調用, 而是原函數定義時,參數的總個數,柯里化函數調用時,滿足了原函數的總個數,就返回計算結果,否則,繼續返回柯里化函數

原函數的入參總個數,可以利用length 屬性獲得

function add(num1, num2) {
    return num1 + num2;
}

add.length // 2

結合上面的代碼,

    var curry = function(f) {
      var len = f.length;
      
        return function t() {
          var innerLength = arguments.length,
            args = Array.prototype.slice.call(arguments);
            
          if (innerLength >= len) {   // 遞歸出口,f.length
             return f.apply(undefined, args)
          } else {
            return function() {
              var innerArgs = Array.prototype.slice.call(arguments),
                allArgs = args.concat(innerArgs);
                
              return t.apply(undefined, allArgs)
            }
          }
        }
    }
    
   // 測試一下
  function add(num1, num2) {
    return num1 + num2;
  }

   var curriedAdd = curry(add);
   add(2)(3);     //5

  // 一個參數
  function identity(value) {
     return value;
 }

   var curriedIdentify = curry(identify);
   curriedIdentify(4) // 4

到此,柯里化通用函數可以滿足大部分需求了。

在使用 apply 遞歸調用的時候,默認傳入 undefined, 在其它場景下,可能需要傳入 context, 綁定指定環境

實際開發,推薦使用 lodash.curry , 具體實現,可以參考下curry源碼

使用場景

講了這麼多curry函數的不同實現方法,那麼實現了通用方法後,在那些場景下可以使用,或者說使用柯里化函數是否可以真實的提高代碼質量,下面總結一下使用場景

  • 參數複用
    在《JavaScript高級程序設計》中簡單版的curry函數中

      var curriedAdd = curry(add, 5)

    在後面,使用curriedAdd函數時,默認都複用了5,不需要重新傳入兩個參數

  • 延遲執行
    上面傳入多個參數的sum(1)(2)(3),就是延遲執行的最後例子,傳入參數個數沒有滿足原函數入參個數,都不會立即返回結果。

    類似的場景,還有綁定事件回調,更使用bind()方法綁定上下文,傳入參數類似,

       addEventListener('click', hander.bind(this, arg1,arg2...))
       
       addEventListener('click', curry(hander)) 
       

    延遲執行的特性,可以避免在執行函數外面,包裹一層匿名函數,curry函數作爲回調函數就有很大優勢。

  • 函數式編程中,作爲compose, functor, monad 等實現的基礎

    有人說柯里化是應函數式編程而生,它在裏面出現的概率就非常大了,在JS 函數式編程指南中,開篇就介紹了柯里化的重要性。

關於額外開銷

函數柯里化可以用來構建複雜的算法 和 功能, 但是濫用也會帶來額外的開銷。

從上面實現部分的代碼中,可以看到,使用柯里化函數,離不開閉包, arguments, 遞歸。

閉包,函數中的變量都保存在內存中,內存消耗大,有可能導致內存泄漏。
遞歸,效率非常差,
arguments, 變量存取慢,訪問性很差,

參考鏈接

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章