JavaScript函式柯里化的一些思考

小qqchen發表於2016-04-27

1. 高階函式的坑

在學習柯里化之前,我們首先來看下面一段程式碼:

var f1 = function(x){
    return f(x);
};
f1(x);

很多同學都能看出來,這些寫是非常傻的,因為函式f1f是等效的,我們直接令var f1 = f;就行了,完全沒有必要包裹那麼一層。

但是,下面一段程式碼就未必能夠看得出問題來了:

var getServerStuff = function(callback){
  return ajaxCall(function(json){
    return callback(json);
  });
};

這是我摘自《JS函數語言程式設計指南》中的一段程式碼,實際上,利用上面的規則,我們可以得出callback與函式

function(json){return callback(json);};

是等價的,所以函式可以化簡為:

var getServerStuff = function(callback){
  return ajaxCall(callback);
};

繼續化簡:

var getServerStuff = ajaxCall;

如此一來,我們發現那麼長一段程式都白寫了。

函式既可以當引數,又可以當返回值,是高階函式的一個重要特性,但是稍不留神就容易踩到坑裡。

2. 函式柯里化(curry)

言歸正傳,什麼是函式柯里化?函式柯里化(curry)就是隻傳遞給函式一部分引數來呼叫它,讓它返回一個函式去處理剩下的引數。聽得很繞口,其實很簡單,其實就是將函式的變數拆分開來呼叫:f(x,y,z) -> f(x)(y)(z)

對於最開始的例子,按照如下實現,要傳入兩個引數,f1呼叫方式是f1(f,x)

var f1 = function(f,x){
    return f(x);
};

注意,由於f是作為一個函式變數傳入,所以f1變成了一個新的函式。

我們將f1變化一下,利用閉包可以寫成如下形式,則f1呼叫方式變成了f1(f)(x),而且得到的結果完全一樣。這就完成了f1的柯里化。

var f1 = function(f){
    return function(x){
        return f(x);
    }
};
var f2 = f1(f);
f2(x);

其實這個例子舉得不恰當,細心的同學可能會發現,f1雖然是一個新函式,但是f2f是完全等效的,繞了半天,還是繞回來了。

這裡有一個很經典的例子:

['11', '11', '11'].map(parseInt) //[ 11, NaN, 3 ]
['11', '11', '11'].map(f1(parseInt)) //[ 11, 11, 11 ]

由於parseInt接受兩個引數,所以直接呼叫會有進位制轉換的問題,參考“不願相離”的文章。

var f2 = f1(parseInt)f2parseInt由原來的接受兩個引數變成了只接受一個引數的新函式,從而解決這個進位制轉換問題。通過我們的f1包裹以後就能夠執行出正確的結果了。

有同學覺得這個不算柯里化的應用,我覺得還是算吧,各位同學可以一起來討論下。

3. 函式柯里化進一步思考

如果說上一節的例子中,我們不是直接執行f(x),而是把函式f當做一個引數,結果會怎樣呢?我們來看下面這個例子:

假設f1返回函式gg的作用域指向xs,函式f作為g的引數。最終我們可以寫成如下形式:

var f1 = function(f,xs){
    return g.call(xs,f);
};

實際上,用f1來替代g.call(xxx)的做法叫反柯里化。例如:

var forEach = function(xs,f){
    return Array.prototype.forEach.call(xs,f);
};
var f = function(x){console.log(x);};
var xs = {0:'peng',1:'chen',length:2};
forEach(xs,f);

反curring就是把原來已經固定的引數或者this上下文等當作引數延遲到未來傳遞。
它能夠在很大程度上簡化函式,前提是你得習慣它。

拋開反柯里化,如果我們要柯里化f1怎麼辦?

使用閉包,我們可以寫成如下形式:

var f1 = function(f){
    return function(xs){
        return g.call(xs,f);
    }
};
var f2 = f1(f);
f2(xs);

f傳入f1中,我們就可以得到f2這個新函式。

只傳給函式一部分引數通常也叫做區域性呼叫(partial application),能夠大量減少樣板檔案程式碼(boilerplate code)。

當然,函式f1傳入的兩個引數不一定非得包含函式+非函式,可能兩個都是函式,也可能兩個都是非函式。

我個人覺得柯里化並非是必須的,而且不熟悉的同學閱讀起來可能會遇到麻煩,但是它能幫助我們理解JS中的函數語言程式設計,更重要的是,我們以後在閱讀類似的程式碼時,不會感到陌生。知乎上羅宸同學講的挺好:

並非“柯里化”對函數語言程式設計有意義。而是,函數語言程式設計在把函式當作一等公民的同時,就不可避免的會產生“柯里化”這種用法。所以它並不是因為“有什麼意義”才出現的。當然既然存在了,我們自然可以探討一下怎麼利用這種現象。

練習:

// 通過區域性呼叫(partial apply)移除所有引數
var filterQs = function(xs) {
  return filter(function(x){ return match(/q/i, x);  }, xs);
};
//這兩個函式原題沒有,是我自己加的
var filter = function(f,xs){
    return xs.filter(f);
};
var match = function(what,x){
    return x.match(what);
};

分析:函式filterQs的作用是:傳入一個字串陣列,過濾出包含’q'的字串,並組成一個新的陣列返回。

我們可以通過如下步驟得到函式filterQs

a. filter傳入的兩個引數,第一個是回撥函式,第二個是陣列,filter主要功能是根據回撥函式過濾陣列。我們首先將filter函式柯里化:

var filter = function(f){
    return function (xs) {
        return xs.filter(f);
    }
};

b. 其次,filter函式傳入的回撥函式是matchmatch的主要功能是判斷每個字串是否匹配what這個正規表示式。這裡我們將match也柯里化:

var match = function(what){
    return function(x){
        return x.match(what);
    }
};
var match2 = match(/q/i);

建立匹配函式match2,檢查字串中是否包含字母q。

c. 把match2傳入filter中,組合在一起,就形成了一個新的函式:

var filterQs =  filter(match2);
var xs = ['q','test1','test2'];
filterQs(xs);

從這個示例中我們也可以體會到函式柯里化的強大。所以,柯里化還有一個重要的功能:封裝不同功能的函式,利用已有的函式組成新的函式。

4. 函式柯里化的遞迴呼叫

函式柯里化還有一種有趣的形式,就是函式可以在閉包中呼叫自己,類似於函式遞迴呼叫。如下所示:

function add( seed ) {
    function retVal( later ) {
        return add( seed + later );
    }
    retVal.toString = function() {
        return seed;
    };
    return retVal;
}
console.log(add(1)(2)(3).toString()); // 6

add函式返回閉包retVal,在retVal中又繼續呼叫add,最終我們可以寫成add(1)(2)(3)(...)這樣柯里化的形式。
關於這段程式碼的解答,知乎上的李巨集訓同學回答地很好:

每呼叫一次add函式,都會返回retValue函式;呼叫retValue函式會呼叫add函式,然後還是返回retValue函式,所以呼叫add的結果一定是返回一個retValue函式。add函式的存在意義只是為了提供閉包,這個類似的遞迴呼叫每次呼叫add都會生成一個新的閉包。

5. 函式組合(compose)

函式組合是在柯里化基礎上完成的:

var compose = function(f,g) {
  return function(x) {
    return f(g(x));
  };
};
var f1 = compose(f,g);
f1(x);

將傳入的函式變成兩個,通過組合的方式返回一個新的函式,讓程式碼從右向左執行,而不是從內向外執行。

函式組合和柯里化有一個好處就是pointfree。

pointfree 模式指的是,永遠不必說出你的資料。它的意思是說,函式無須提及將要操作的資料是什麼樣的。一等公民的函式、柯里化(curry)以及組合協作起來非常有助於實現這種模式。

// 非 pointfree,因為提到了資料:name
var initials = function (name) {
  return name.split(' ').map(compose(toUpperCase, head)).join('. ');
};

// pointfree
var initials = compose(join('. '), map(compose(toUpperCase, head)), split(' '));

initials("hunter stockton thompson");
// 'H. S. T'

相關文章