局部應用(Partial Application,也譯做「偏應用」或「部分應用」)和局部
套用( Currying, 也譯做「柯里化」),是函數式編程範式中很經常使用的技巧。
本文着重於闡述它們的特色和(更重要的是)差別。編程
在後續的代碼示例中,會頻繁出現 unary(一元),binary(二元),
ternary(三元)或 polyadic(多元,即多於一元)以及 variadic(可變
元)等數學用語。在本文所表述的範圍內,它們都是用來描述函數的參數數量的。數組
先來一個「無聊」的例子,實現一個 map
函數:數據結構
function map(list, unaryFn) { return [].map.call(list, unaryFn); } function square(n) { return n * n; } map([2, 3, 5], square); // => [4, 9, 25]
這個例子固然缺少實用價值,咱們僅僅是仿造了數組的原型方法 map
而已,不
過相似的應用場景仍是能夠想象獲得的。那麼這個例子和局部應用有什麼關聯呢?app
如下是一些客觀陳述的事實(可是很重要,確保你看明白了):函數式編程
map
是一個二元函數;square
是一個一元函數;map
時,咱們傳入了兩個參數([2, 3, 5]
和 square
),map
函數裏,並返回給咱們最終的結果。簡單明瞭吧?因爲 map
要兩個參數,咱們也給了兩個參數,因而咱們能夠說:函數
map
函數 徹底應用 了咱們傳入的參數。code
而所謂局部應用就像它的字面意思同樣,函數調用的時候只提供部分參數供其應用
——比方說上例,調用 map
的時候只傳給它一個參數。htm
但是這要怎麼實現呢?對象
首先,咱們把 map
包裝一下:ip
function mapWith(list, unaryFn) { return map(list, unaryFn); }
而後,咱們把二元的包裝函數變成兩個層疊的一元函數:
function mapWith(unaryFn) { return function (list) { return map(list, unaryFn); }; }
因而,這個包裝函數就變成了:先接收一個參數,而後返回給咱們一個函數來接受
第二個參數,最終再返回結果。也就是這樣:
mapWith(square)([2, 3, 5]); // => [4, 9, 25]
到目前爲止,局部應用彷佛沒有體現出什麼特別的價值,然而若是咱們把應用場景
稍微擴展一下的話……
var squareAll = mapWith(square); squareAll([2, 3, 5]); // => [4, 9, 25] squareAll([1, 4, 7, 6]); // => [1, 16, 49, 36]
咱們把對象 square
(函數即對象)做爲部分參數應用在 map
函數中,獲得一
個一元函數,即 squareAll
,因而咱們能夠想怎麼用就怎麼用。這就是局部應用
,恰當的使用這個技巧會很是有用。
咱們能夠在局部應用的例子的基礎上繼續探索局部套用,首先把前面的 mapWith
稍微修整修整:
function wrapper(unaryFn) { return function(list) { return binaryFn(list, unaryFn); }; }
function wrapper(secondArg) { return function(firstArg) { return binaryFn(firstArg, secondArg); }; }
如上,我刻意把修整分做兩步來寫。第一步,咱們把 map
用一個更抽象的binaryFn
取代,暗示咱們不侷限於作數組映射,能夠是任何一種二元函數的處
理;同時,最外層的 mapWith
也就沒有必要了,使用更抽象的 wrapper
取代
。第二步,既然用做處理的函數都抽象化了,傳入的參數天然也沒有必要限定其類
型,因而就獲得了最終的形態。
接下來的思考很是關鍵,請跟緊咯!
考慮一下未修整前的形態,最裏層的 map
是哪裏來的?——那是咱們在最開始
的時候本身定義的。然而到了修整後的形態,binaryFn
是個抽象的概念,此時
此刻咱們並無對應的函數能夠直接調用它,那麼咱們要如何提供這一步?
再包裝一層,把 binaryFn
做爲參數傳進來——
1 function rightmostCurry(binaryFn) { 2 return function (secondArg) { 3 return function (firstArg) { 4 return binaryFn(firstArg, secondArg); 5 }; 6 }; 7 }
你是否意識到這其實就是函數式編程的本質(的體現形式之一)?
那麼,局部套用是如何體現出來的呢?咱們把一開始寫的那個 map
函數套用進
來玩玩:
var rightmostCurriedMap = rightmostCurry(map); var squareAll = rightmostCurriedMap(square); squareAll([2, 3, 5]); // => [4, 9, 25] squareAll([1, 4, 7, 6]); // => [1, 16, 49, 36]
最後三句和以前講局部應用的例子是同樣的,局部套用的體現就在第一句上。乍一
看,這貌似就是又多了一層局部應用而已啊?不,它們是有差異的!
對比一下兩個例子:
// 局部應用 function mapWith(unaryFn) { return function (list) { return map(list, unaryFn); }; } // 局部套用 1 function rightmostCurry(binaryFn) { 2 return function (secondArg) { 3 return function (firstArg) { 4 return binaryFn(firstArg, secondArg); 5 }; 6 }; 7 }
在局部應用的例子裏,最內層的處理函數是肯定的;換言之,咱們對最終的處理方
式是有預期的。咱們只是把傳入參數分批完成,以得到:一)較大的應用靈活性;
二)更單純的函數調用形態。
而在局部套用的例子裏,第 2~6
行仍是局部應用——這沒差異;可是能夠看出
最內層的處理在定義的時候實際上是未知的,而第 1
行的目的是爲了傳入用於最
終處理的函數。所以咱們須要先傳入進行最終處理的函數,而後再給它分批傳入參
數(局部應用),以得到更大的應用靈活性。
回過頭來解讀一下這兩個名詞:
在前面的例子中,爲何要把局部套用函數命名爲 rightmostCurry
?另外,是
否還有與之對應的 leftmostCurry
呢?
請回頭再看一眼上例的第 2~6
行,會發現層疊的兩個一元函數先傳入secondArg
,再傳入 firstArg
,而最內層的處理函數則是反過來的。如此一來
,咱們先接受最右邊的,再接受最左邊的,這就叫最右形式的局部套用;反之則是
最左形式的局部套用。
即便在本文的例子裏都使用二元參數,但其實多元也是同樣的,無非就是增長局
部應用的層疊數量;而可變元的應用也不難,徹底能夠用某種數據結構來封裝多
個元參數(如數組)而後再進行解構處理——ES6 的改進會讓這一點變得更加簡
單。
可是這又有什麼實際意義呢?仔細對比下面兩個代碼示例:
function rightmostCurry(binaryFn) { return function (secondArg) { return function (firstArg) { return binaryFn(firstArg, secondArg); }; }; } var rightmostCurriedMap = rightmostCurry(map); function square(n) { return n * n; } var squareAll = rightmostCurriedMap(square); squareAll([2, 3, 5]); // => [4, 9, 25] squareAll([1, 4, 7, 6]); // => [1, 16, 49, 36]
function leftmostCurry(binaryFn) { return function (firstArg) { return function (secondArg) { return binaryFn(firstArg, secondArg); }; }; } var leftmostCurriedMap = leftmostCurry(map); function square(n) { return n * n; } function double(n) { return n + n; } var oneToThreeEach = leftmostCurriedMap([1, 2, 3]); oneToThreeEach(square); // => [1, 4, 9] oneToThreeEach(double); // => [2, 4, 6]
這兩個例子很容易理解,我想就無須贅述了。值得注意的是,因爲「從左向右」的
處理更合邏輯一些,因此現實中最左形式的局部套用比較常見,並且習慣上直接把
最左形式的局部套用就叫作 curry,因此若是沒有顯式的 rightmost 出現,
那麼就能夠按照慣例認爲它是最左形式的。
最後,什麼時候用最左形式什麼時候用最右形式?嗯……這個其實沒有規定的,徹底取決於
你的應用場景更適合用哪一種形式來表達。從上面的對比中能夠發現一樣的局部套用
(都套用 map
),最左形式和最右形式會對應用形態的語義化表達產生不一樣的影
響:
squareAll([...])
,它的潛臺詞是:無論傳入square
是主體,而oneToThreeEach(...)
,沒必要說,天然是以前傳入[1, 2, 3]
是主體,而以後傳入的 square
或 double
纔是客體;因此說,根據應用的場景來選擇最合適的形式吧,沒必要拘泥於特定的某種形式。
至此,咱們已經把局部應用和局部套用的微妙差異分析的透徹了,但這更多的是理
論性質的研究罷了,現實中這二者的界限則很是模糊——因此不少人習慣混爲一談
也就不很意外了。
就拿 rightmostCurry
那個例子來講吧:
function rightmostCurry(binaryFn) { return function (secondArg) { return function (firstArg) { return binaryFn(firstArg, secondArg); }; }; }
像這樣局部套用摻雜着局部應用的代碼在現實中只能算是「半成品」,爲何呢?
由於你很快會發現這樣的尷尬:
var squareAll = rightmostCurry(map)(square); var doubleAll = rightmostCurry(map)(double);
像這樣的「先局部套用而後緊接着局部應用」的模式是很是廣泛的,咱們爲何不
進一步抽象化它呢?
對於廣泛化的模式,人們習慣於給它一個命名。對於上面的例子,可分解描述爲:
map
理一理語序能夠組合成:針對 map
的最右形式(局部套用)的一元局部應用。
真尼瑪的囉嗦!
實際上咱們真正想作的是:先給 map
函數局部應用一個參數,返回的結果能夠
繼續應用 map
須要的另一個參數(固然,你能夠把 map
替換成其餘的函
數,這就是局部套用的職責表現了)。真正留給咱們要實現的僅僅是返回另一部
分用於局部應用的一元函數罷了。
所以按照函數式編程的習慣,rightmostCurry
能夠簡化成:
function rightmostUnaryPartialApplication(binaryFn, secondArg) { return rightmostCurry(binaryFn, secondArg); }
先別管冗長的命名,接着咱們套用局部應用的技巧,進一步改寫成更簡明易懂的形
式:
function rightmostUnaryPartialApplication(binaryFn, secondArg) { return function (firstArg) { return binaryFn(firstArg, secondArg); }; }
這纔是你在現實中隨處可見的「徹底形態」!至於冗長的命名,小問題啦:
var applyLast = rightmostUnaryPartialApplication; var squareAll = applyLast(map, square); var doubleAll = applyLast(map, double);
如此一來,最左形式的類似實現就能夠無腦出爐了:
function applyFirst(binaryFn, firstArg) { return function (secondArg) { return binaryFn(firstArg, secondArg); }; }
其實這樣的代碼不少開發者都已經寫過無數次了,但是若是你請教這是什麼寫法,
回答你「局部應用」或「局部套用」的都會有。對於初學者來講就容易鬧不清楚到
底有什麼區別,長此以往就乾脆認爲是一回事兒了。不過如今你應該明白過來了,
這個徹底體實際上是「局部應用」和「局部套用」的綜合應用。
各用一句話作個小結吧:
局部應用(Partial Application):是一種轉換技巧,經過預先傳入一個或多
個參數來把多元函數轉變爲更少一些元的函數甚或是一元函數。
局部套用(Currying):是一種解構技巧,用於把多元函數分解爲多個可鏈式調
用的層疊式的一元函數,這種解構能夠容許你在其中局部應用一個或多個參數,但
是局部套用自己不提供任何參數——它提供的是調用鏈裏的最終處理函數。
後記:撰寫本文的時間跨度較長,期間參考的資料和代碼沒法一一計數。可是
Raganwald 的書和博客 以及 Michael Fogue 的 Functional JavaScript 給 予個人幫助和指導是我難以忘記的,在此向兩位以及全部幫助個人大牛們致謝!