iThome 網站首載:函式的鞣製化
由於具一級函式的語言漸受重用,函式的鞣製化(Currying)應用時有所見,從多數命令式語言看鞣製函式(Curried function),容易令人有摸不著邊際的感覺,其實在函數式語言中,鞣製函式是很自然的特性。當看不清楚特性的本質時,尋找特性的起源點,往往令人驚訝特性本質如此單純。
- 函數式語言中的鞣製函式
在我先前的專欄 物件導向語言中的一級函式 談過,在lambda演算中,每個表達式(Expression)代表具單一參數的函數,多參數函數可使用單參數函數傳回單參數函數連續套用來表示,這樣的轉換稱為鞣製化,每次對單參數函數套用引數稱為部份套用(Partially application)或部分求值(Partial Evaluation)。
以純函數式語言Haskell為例,相加兩數的函式可定義為plus a b = a + b,若plus 1 2可得到3的結果,就函式外觀來看,plus似乎具備兩個參數,實際上Haskell的函式天生就是鞣製函式,plus的型態是Num a => a -> a -> a,將a -> a -> a寫為a -> (a -> a)會更為清楚,也就是表示套用參數a之後傳回a -> a的函式,具體來說,plus 1 2其實是plus 1傳回單參數函式\\b -> 1 + b,再對傳回函式套用2而得到3的結果,將plus 1 2寫為(plus 1) 2就可更清楚表示出套用的過程。
由於對鞣製函式進行部份套用會傳回函式,只要對鞣製函式套用不同值或不同個數的引數,就好似從既有函式產生新的函式定義。舉例來說,若要對數列[1, 2, 3]都加3而產生新的數列,可以使用map函式結合匿名函式撰寫為map (\elem -> elem + 3) [1, 2, 3],若早已定義先前的plus函式,可無需定義匿名函式直接撰寫為map (plus 3) [1, 2, 3],事實上Haskell中+也是函式,所以就算沒有定義plus函式,也可以直接寫為map (+3) [1, 2, 3],不僅避免自行定義匿名函式,程式撰寫上也簡潔許多。
- 實現命令式語言中的鞣製函式
多數命令式語言並不直接支援鞣製函式,想要得到類似鞣製函式的優點,往往必須自行實作。以JavaScript為例,若定義function plus(a, b) { return a + b; },只能用plus(1, 2)的方式來呼叫,呼叫plus(1)部份套用只會得到NaN的結果。若要實現鞣製函式概念,可簡單地將function plus(a, b)的函式本體撰寫為return function(b) { return a + b; },如此呼叫plus(1)會傳回具單參數b而傳回值為1 + b的函式。
Currying名稱由來是為了紀念美國數學家暨邏輯學家Haskell Brooks Curry,然而就本身不支援此特性的語言來說,Currying過程好比將數個函式鞣製在一起,然而以上使用JavaScript實現plus函式的鞣製化,對於全套用(Fully applied)的情況,必須使用plus(1)(3)方式呼叫,有異於JavaScript本身的函式呼叫方式。Haskell中由於函式定義與呼叫時都不使用括號與逗號,定義鞣製函式與進行部份套用時都很自然。如果考慮JavaScript中全套用時可使用plus(1, 3),部份套用時可使用plus(1)傳回新函式,則必須額外下些功夫。例如可如下簡單地實現:
function plus(a, b) {
if(arguments.length == 2) return a + b;
else return function(b) { return a + b; };
}
if(arguments.length == 2) return a + b;
else return function(b) { return a + b; };
}
如此一來,若想對[1, 2, 3]進行加3而產生新的數列,只要[1, 2, 3].map(plus(3)),相較於自行定義匿名函式的寫法[1, 2, 3].map(function(elem) {return elem + 3}),著實簡潔許多。若語言的特性支援,亦可定義通用的currying函式,針對未鞣製化的函式進行鞣製,如此開發者可以傳統方式定義函式,在必要時明確以currying函式進行鞣製化。例如如下定義的plus函式,既可以plus(1, 2)呼叫,也可以plus(3)傳回新函式:
var plus = currying(function(a, b) {
return a + b;
});
return a + b;
});
- 鞣製化概念的延伸
有些開發者在不強求部份套用能力的情況下,將鞣製函式的概念延伸為可傳回函式的函式,這類函式在接受引數進行運算後,傳回的函式會保留運算結果,之後就可使用傳回的函式,基於前次運算基礎進行進一步操作。例如計算費式數(Fibonacci Number)的JavaScript函式可如下定義:
var fibonacci = function() {
var fib = [0, 1]; // 用來快取已計算的費式數
return function(n) {
// ... 基於既有的fib計算新費式數,並存入fib陣列
return fib[n];
};
}();
var fib = [0, 1]; // 用來快取已計算的費式數
return function(n) {
// ... 基於既有的fib計算新費式數,並存入fib陣列
return fib[n];
};
}();
上例的匿名函式執行過後會傳回另一匿名函式,該函式保留了fib,如此fibonacci(6)後,fib就會保有6個費式數,下次再呼叫fibonacci(7),就可基於原有fib中的費式數進行運算,節省重複運算的成本。將這種保有前次運算狀態並傳回可執行值的特點再加以延伸,就是使用傳回物件來保有前次操作成果,以便開發者進一步作後續方法呼叫,而形成所謂方法鏈(Method chain)。例如JavaScript著名程式庫jQuery,使用特色之一就是簡明的jQuery chain風格:
\$('img').addClass('seeThrough')
.filter('[title*=dog]')
.addClass('thickBorder');
.filter('[title*=dog]')
.addClass('thickBorder');
由於每次傳回物件都會記得前次運算結果,也常見應用於參數收集與設定。例如Hibernate的Criteria API,就可撰寫為以下風格:
session.createCriteria(User.class)
.setFirstResult(51)
.setMaxResults(50)
.list()
.setFirstResult(51)
.setMaxResults(50)
.list()
由於每次傳回物件都會記得前次設定,亦可用於記憶參數接受的運算式,達到延遲求值(Lazy Evaluation)或捷徑(short-circuiting)效果。例如JDK8中可撰寫blocks.filter(b -> b.getColor() == BLUE).getFirst(),blocks長度若為1000,實際上filter時並不會真的就傳入的lambda運算式求值1000次,而會在getFirst時才運用,並在第一個lambda運算式結果為true時就終止運算並傳回結果。
- 思考傳回值為最終結果或需具備可執行性
如果語言本身支援鞣製函式,部份套用看似可從既有函式產生新的函式定義,事實上是傳回事先定義好的單參數函式,在全套用情況下,實際運算是連續單參數函式逐一套用引數的結果。部份套用的意義在於取得最後運算結果所需資訊不足,因而保留運算狀態或甚至傳入的運算式,傳回具有可執行能力的函式,以在後續取得剩餘資訊並完成最後運算結果。
因此無論語言本身是否支援鞣製函式,都可思考函式或方法執行過後傳回值是否應具備可執行性。函式套用參數後是否應傳回函式,以便處理下一個參數,物件方法套用參數之後,是否應傳回物件以便呼叫某方法處理下一個參數。在某些情況下,與其預測參數的個數與型態,不如採用鞣製化概念,讓傳回值具有處理參數的能力,如此處理參數時將擁有更自由或更高階抽象的組合方式,讓問題得以更優雅地解決。