摘要:但是,對函數式編程而言,這個行為的重要性是毋庸置疑的。關于該模式更正式的說法是偏函數嚴格來講是一個減少函數參數個數的過程這里的參數個數指的是希望傳入的形參的數量。
原文地址:Functional-Light-JS
原文作者:Kyle Simpson-《You-Dont-Know-JS》作者
第 3 章:管理函數的輸入(Inputs)關于譯者:這是一個流淌著滬江血液的純粹工程:認真,是 HTML 最堅實的梁柱;分享,是 CSS 里最閃耀的一瞥;總結,是 JavaScript 中最嚴謹的邏輯。經過捶打磨練,成就了本書的中文版。本書包含了函數式編程之精髓,希望可以幫助大家在學習函數式編程的道路上走的更順暢。比心。
譯者團隊(排名不分先后):阿希、blueken、brucecham、cfanlife、dail、kyoko-df、l3ve、lilins、LittlePineapple、MatildaJin、冬青、pobusama、Cherry、蘿卜、vavd317、vivaxy、萌萌、zhouyao
在第 2 章的 “函數輸入” 小節中,我們聊到了函數形參(parameters)和實參(arguments)的基本知識,實際上還了解到一些能簡化其使用方式的語法技巧,比如 ... 操作符和解構(destructuring)。
在那個討論中,我建議盡可能設計單一形參的函數。但實際上你不能每次都做到,而且也不能每次都掌控你的函數簽名(譯者注:JS 中,函數簽名一般包含函數名和形參等函數關鍵信息,例如 foo(a, b = 1, c))。
現在,我們把注意力放在更復雜、強大的模式上,以便討論處在這些場景下的函數輸入。
立即傳參和稍后傳參如果一個函數接收多個實參,你可能會想先指定部分實參,余下的稍后再指定。
來看這個函數:
function ajax(url,data,callback) { // .. }
想象一個場景,你要發起多個已知 URL 的 API 請求,但這些請求的數據和處理響應信息的回調函數要稍后才能知道。
當然,你可以等到這些東西都確定后再發起 ajax(..) 請求,并且到那時再引用全局 URL 常量。但我們還有另一種選擇,就是創建一個已經預設 url 實參的函數引用。
我們將創建一個新函數,其內部仍然發起 ajax(..) 請求,此外在等待接收另外兩個實參的同時,我們手動將 ajax(..) 第一個實參設置成你關心的 API 地址。
function getPerson(data,cb) { ajax( "http://some.api/person", data, cb ); } function getOrder(data,cb) { ajax( "http://some.api/order", data, cb ); }
手動指定這些外層函數當然是完全有可能的,但這可能會變得冗長乏味,特別是不同的預設實參還會變化的時候,譬如:
function getCurrentUser(cb) { getPerson( { user: CURRENT_USER_ID }, cb ); }
函數式編程者習慣于在重復做同一種事情的地方找到模式,并試著將這些行為轉換為邏輯可重用的實用函數。實際上,該行為肯定已是大多數讀者的本能反應了,所以這并非函數式編程獨有。但是,對函數式編程而言,這個行為的重要性是毋庸置疑的。
為了構思這個用于實參預設的實用函數,我們不僅要著眼于之前提到的手動實現方式,還要在概念上審視一下到底發生了什么。
用一句話來說明發生的事情:getOrder(data,cb) 是 ajax(url,data,cb) 函數的偏函數(partially-applied functions)。該術語代表的概念是:在函數調用現場(function call-site),將實參應用(apply) 于形參。如你所見,我們一開始僅應用了部分實參 —— 具體是將實參應用到 url 形參 —— 剩下的實參稍后再應用。
關于該模式更正式的說法是:偏函數嚴格來講是一個減少函數參數個數(arity)的過程;這里的參數個數指的是希望傳入的形參的數量。我們通過 getOrder(..) 把原函數 ajax(..) 的參數個數從 3 個減少到了 2 個。
讓我們定義一個 partial(..) 實用函數:
function partial(fn,...presetArgs) { return function partiallyApplied(...laterArgs){ return fn( ...presetArgs, ...laterArgs ); }; }
建議: 只是走馬觀花是不行的。請花些時間研究一下該實用函數中發生的事情。請確保你真的理解了。由于在接下來的文章里,我們將會一次又一次地提到該模式,所以你最好現在就適應它。
partial(..) 函數接收 fn 參數,來表示被我們偏應用實參(partially apply)的函數。接著,fn 形參之后,presetArgs 數組收集了后面傳入的實參,保存起來稍后使用。
我們創建并 return 了一個新的內部函數(為了清晰明了,我們把它命名為partiallyApplied(..)),該函數中,laterArgs 數組收集了全部實參。
你注意到在內部函數中的 fn 和 presetArgs 引用了嗎?他們是怎么如何工作的?在函數 partial(..) 結束運行后,內部函數為何還能訪問 fn 和 presetArgs 引用?你答對了,就是因為閉包!內部函數 partiallyApplied(..) 封閉(closes over)了 fn 和 presetArgs 變量,所以無論該函數在哪里運行,在 partial(..) 函數運行后我們仍然可以訪問這些變量。所以理解閉包是多么的重要!
當 partiallyApplied(..) 函數稍后在某處執行時,該函數使用被閉包作用(closed over)的 fn 引用來執行原函數,首先傳入(被閉包作用的)presetArgs 數組中所有的偏應用(partial application)實參,然后再進一步傳入 laterArgs 數組中的實參。
如果你對以上感到任何疑惑,請停下來再看一遍。相信我,隨著我們進一步深入本文,你會欣然接受這個建議。
提一句,對于這類代碼,函數式編程者往往喜歡使用更簡短的 => 箭頭函數語法(請看第 2 章的 “語法” 小節),像這樣:
var partial = (fn, ...presetArgs) => (...laterArgs) => fn( ...presetArgs, ...laterArgs );
毫無疑問這更加簡潔,甚至代碼稀少。但我個人覺得,無論我們從數學符號的對稱性上獲得什么好處,都會因函數變成了匿名函數而在整體的可讀性上失去更多益處。此外,由于作用域邊界變得模糊,我們會更加難以辯認閉包。
不管你喜歡哪種語法實現方式,現在我們用 partial(..) 實用函數來制造這些之前提及的偏函數:
var getPerson = partial( ajax, "http://some.api/person" ); var getOrder = partial( ajax, "http://some.api/order" );
請暫停并思考一下 getPerson(..) 函數的外形和內在。它相當于下面這樣:
var getPerson = function partiallyApplied(...laterArgs) { return ajax( "http://some.api/person", ...laterArgs ); };
創建 getOrder(..) 函數可以依葫蘆畫瓢。但是 getCurrentUser(..) 函數又如何呢?
// 版本 1 var getCurrentUser = partial( ajax, "http://some.api/person", { user: CURRENT_USER_ID } ); // 版本 2 var getCurrentUser = partial( getPerson, { user: CURRENT_USER_ID } );
我們可以(版本 1)直接通過指定 url 和 data 兩個實參來定義 getCurrentUser(..) 函數,也可以(版本 2)將 getCurrentUser(..) 函數定義成 getPerson(..) 的偏應用,該偏應用僅指定一個附加的 data 實參。
因為版本 2 重用了已經定義好的函數,所以它在表達上更清晰一些。因此我認為它更加貼合函數式編程精神。
版本 1 和 2 分別相當于下面的代碼,我們僅用這些代碼來確認一下對兩個函數版本內部運行機制的理解。
// 版本 1 var getCurrentUser = function partiallyApplied(...laterArgs) { return ajax( "http://some.api/person", { user: CURRENT_USER_ID }, ...laterArgs ); }; // 版本 2 var getCurrentUser = function outerPartiallyApplied(...outerLaterArgs) { var getPerson = function innerPartiallyApplied(...innerLaterArgs){ return ajax( "http://some.api/person", ...innerLaterArgs ); }; return getPerson( { user: CURRENT_USER_ID }, ...outerLaterArgs ); }
再強調一下,為了確保你理解這些代碼段發生了什么,請暫停并重新閱讀一下它們。
注意: 第二個版本的函數包含了一個額外的函數包裝層。這看起來有些奇怪而且多余,但對于你真正要適應的函數式編程來說,這僅僅是它的冰山一角。隨著本文的繼續深入,我們將會把許多函數互相包裝起來。記住,這就是函數式編程!
我們接著看另外一個偏應用的實用示例。設想一個 add(..) 函數,它接收兩個實參,并取二者之和:
function add(x,y) { return x + y; }
現在,想象我們要拿到一個數字列表,并且給其中每個數字加一個確定的數值。我們將使用 JS 數組對象內置的 map(..) 實用函數。
[1,2,3,4,5].map( function adder(val){ return add( 3, val ); } ); // [4,5,6,7,8]
注意: 如果你沒見過 map(..) ,別擔心,我們會在本書后面的部分詳細介紹它。目前你只需要知道它用來循環遍歷(loop over)一個數組,在遍歷過程中調用函數產出新值并存到新的數組中。
因為 add(..) 函數簽名不是 map(..) 函數所預期的,所以我們不直接把它傳入 map(..) 函數里。這樣一來,偏應用就有了用武之地:我們可以調整 add(..) 函數簽名,以符合 map(..) 函數的預期。
[1,2,3,4,5].map( partial( add, 3 ) ); // [4,5,6,7,8]bind(..)
JavaScript 有一個內建的 bind(..) 實用函數,任何函數都可以使用它。該函數有兩個功能:預設 this 關鍵字的上下文,以及偏應用實參。
我認為將這兩個功能混合進一個實用函數是極其糟糕的決定。有時你不想關心 this 的綁定,而只是要偏應用實參。我本人基本上從不會同時需要這兩個功能。
對于下面的方案,你通常要傳 null 給用來綁定 this 的實參(第一個實參),而它是一個可以忽略的占位符。因此,這個方案非常糟糕。
請看:
var getPerson = ajax.bind( null, "http://some.api/person" );
那個 null 只會給我帶來無盡的煩惱。
將實參順序顛倒回想我們之前調用 Ajax 函數的方式:ajax( url, data, cb )。如果要偏應用 cb 而稍后再指定 data 和 url 參數,我們應該怎么做呢?我們可以創建一個可以顛倒實參順序的實用函數,用來包裝原函數。
function reverseArgs(fn) { return function argsReversed(...args){ return fn( ...args.reverse() ); }; } // ES6 箭頭函數形式 var reverseArgs = fn => (...args) => fn( ...args.reverse() );
現在可以顛倒 ajax(..) 實參的順序了,接下來,我們不再從左邊開始,而是從右側開始偏應用實參。為了恢復期望的實參順序,接著我們又將偏應用實參后的函數顛倒一下實參順序:
var cache = {}; var cacheResult = reverseArgs( partial( reverseArgs( ajax ), function onResult(obj){ cache[obj.id] = obj; } ) ); // 處理后: cacheResult( "http://some.api/person", { user: CURRENT_USER_ID } );
好,我們來定義一個從右邊開始偏應用實參(譯者注:以下簡稱右偏應用實參)的 partialRight(..) 實用函數。我們將運用和上面相同的技巧于該函數中:
function partialRight( fn, ...presetArgs ) { return reverseArgs( partial( reverseArgs( fn ), ...presetArgs.reverse() ) ); } var cacheResult = partialRight( ajax, function onResult(obj){ cache[obj.id] = obj; }); // 處理后: cacheResult( "http://some.api/person", { user: CURRENT_USER_ID } );
這個 partialRight(..) 函數的實現方案不能保證讓一個特定的形參接收特定的被偏應用的值;它只能確保將被這些值(一個或幾個)當作原函數最右邊的實參(一個或幾個)傳入。
舉個例子:
function foo(x,y,z) { var rest = [].slice.call( arguments, 3 ); console.log( x, y, z, rest ); } var f = partialRight( foo, "z:last" ); f( 1, 2 ); // 1 2 "z:last" [] f( 1 ); // 1 "z:last" undefined [] f( 1, 2, 3 ); // 1 2 3 ["z:last"] f( 1, 2, 3, 4 ); // 1 2 3 [4,"z:last"]
只有在傳兩個實參(匹配到 x 和 y 形參)調用 f(..) 函數時,"z:last" 這個值才能被賦給函數的形參 z。在其他的例子里,不管左邊有多少個實參,"z:last" 都被傳給最右的實參。
一次傳一個我們來看一個跟偏應用類似的技術,該技術將一個期望接收多個實參的函數拆解成連續的鏈式函數(chained functions),每個鏈式函數接收單一實參(實參個數:1)并返回另一個接收下一個實參的函數。
這就是柯里化(currying)技術。
首先,想象我們已創建了一個 ajax(..) 的柯里化版本。我們這樣使用它:
curriedAjax( "http://some.api/person" ) ( { user: CURRENT_USER_ID } ) ( function foundUser(user){ /* .. */ } );
我們將三次調用分別拆解開來,這也許有助于我們理解整個過程:
var personFetcher = curriedAjax( "http://some.api/person" ); var getCurrentUser = personFetcher( { user: CURRENT_USER_ID } ); getCurrentUser( function foundUser(user){ /* .. */ } );
該 curriedAjax(..) 函數在每次調用中,一次只接收一個實參,而不是一次性接收所有實參(像 ajax(..) 那樣),也不是先傳部分實參再傳剩余部分實參(借助 partial(..) 函數)。
柯里化和偏應用相似,每個類似偏應用的連續柯里化調用都把另一個實參應用到原函數,一直到所有實參傳遞完畢。
不同之處在于,curriedAjax(..) 函數會明確地返回一個期望只接收下一個實參 data 的函數(我們把它叫做 curriedGetPerson(..)),而不是那個能接收所有剩余實參的函數(像此前的 getPerson(..) 函數) 。
如果一個原函數期望接收 5 個實參,這個函數的柯里化形式只會接收第一個實參,并且返回一個用來接收第二個參數的函數。而這個被返回的函數又只接收第二個參數,并且返回一個接收第三個參數的函數。依此類推。
由此而知,柯里化將一個多參數(higher-arity)函數拆解為一系列的單元鏈式函數。
如何定義一個用來柯里化的實用函數呢?我們將要用到第 2 章中的一些技巧。
function curry(fn,arity = fn.length) { return (function nextCurried(prevArgs){ return function curried(nextArg){ var args = prevArgs.concat( [nextArg] ); if (args.length >= arity) { return fn( ...args ); } else { return nextCurried( args ); } }; })( [] ); }
ES6 箭頭函數版本:
var curry = (fn, arity = fn.length, nextCurried) => (nextCurried = prevArgs => nextArg => { var args = prevArgs.concat( [nextArg] ); if (args.length >= arity) { return fn( ...args ); } else { return nextCurried( args ); } } )( [] );
此處的實現方式是把空數組 [] 當作 prevArgs 的初始實參集合,并且將每次接收到的 nextArg 同 prevArgs 連接成 args 數組。當 args.length 小于 arity(原函數 fn(..) 被定義和期望的形參數量)時,返回另一個 curried(..) 函數(譯者注:這里指代 nextCurried(..) 返回的函數)用來接收下一個 nextArg 實參,與此同時將 args 實參集合作為唯一的 prevArgs 參數傳入 nextCurried(..) 函數。一旦我們收集了足夠長度的 args 數組,就用這些實參觸發原函數 fn(..)。
默認地,我們的實現方案基于下面的條件:在拿到原函數期望的全部實參之前,我們能夠通過檢查將要被柯里化的函數的 length 屬性來得知柯里化需要迭代多少次。
假如你將該版本的 curry(..) 函數用在一個 length 屬性不明確的函數上 —— 函數的形參聲明包含默認形參值、形參解構,或者它是可變參數函數,用 ...args 當形參;參考第 2 章 —— 你將要傳入 arity 參數(作為 curry(..) 的第二個形參)來確保 curry(..) 函數的正常運行。
我們用 curry(..) 函數來實現此前的 ajax(..) 例子:
var curriedAjax = curry( ajax ); var personFetcher = curriedAjax( "http://some.api/person" ); var getCurrentUser = personFetcher( { user: CURRENT_USER_ID } ); getCurrentUser( function foundUser(user){ /* .. */ } );
如上,我們每次函數調用都會新增一個實參,最終給原函數 ajax(..) 使用,直到收齊三個實參并執行 ajax(..) 函數為止。
還記得前面講到為數值列表的每個值加 3 的那個例子嗎?回顧一下,由于柯里化是和偏應用相似的,所以我們可以用幾乎相同的方式以柯里化來完成那個例子。
[1,2,3,4,5].map( curry( add )( 3 ) ); // [4,5,6,7,8]
partial(add,3) 和 curry(add)(3) 兩者有什么不同呢?為什么你會選 curry(..) 而不是偏函數呢?當你先得知 add(..) 是將要被調整的函數,但如果這個時候并不能確定 3 這個值,柯里化可能會起作用:
var adder = curry( add ); // later [1,2,3,4,5].map( adder( 3 ) ); // [4,5,6,7,8]
讓我們來看看另一個有關數字的例子,這次我們拿一個列表的數字做加法:
function sum(...args) { var sum = 0; for (let i = 0; i < args.length; i++) { sum += args[i]; } return sum; } sum( 1, 2, 3, 4, 5 ); // 15 // 好,我們看看用柯里化怎么做: // (5 用來指定需要鏈式調用的次數) var curriedSum = curry( sum, 5 ); curriedSum( 1 )( 2 )( 3 )( 4 )( 5 ); // 15
這里柯里化的好處是,每次函數調用傳入一個實參,并生成另一個特定性更強的函數,之后我們可以在程序中獲取并使用那個新函數。而偏應用則是預先指定所有將被偏應用的實參,產出一個等待接收剩下所有實參的函數。
如果想用偏應用來每次指定一個形參,你得在每個函數中逐次調用 partialApply(..) 函數。而被柯里化的函數可以自動完成這個工作,這讓一次多帶帶傳遞一個參數變得更加符合人機工程學。
在 JavaScript 中,柯里化和偏應用都使用閉包來保存實參,直到收齊所有實參后我們再執行原函數。
柯里化和偏應用有什么用?無論是柯里化風格(sum(1)(2)(3))還是偏應用風格(partial(sum,1,2)(3)),它們的簽名比普通函數簽名奇怪得多。那么,在適應函數式編程的時候,我們為什么要這么做呢?答案有幾個方面。
首先是顯而易見的理由,使用柯里化和偏應用可以將指定分離實參的時機和地方獨立開來(遍及代碼的每一處),而傳統函數調用則需要預先確定所有實參。如果你在代碼某一處只獲取了部分實參,然后在另一處確定另一部分實參,這個時候柯里化和偏應用就能派上用場。
另一個最能體現柯里化應用的的是,當函數只有一個形參時,我們能夠比較容易地組合它們。因此,如果一個函數最終需要三個實參,那么它被柯里化以后會變成需要三次調用,每次調用需要一個實參的函數。當我們組合函數時,這種單元函數的形式會讓我們處理起來更簡單。我們將在后面繼續探討這個話題。
如何柯里化多個實參?到目前為止,我相信我給出的是我們能在 JavaScript 中能得到的,最精髓的柯里化定義和實現方式。
具體來說,如果簡單看下柯里化在 Haskell 語言中的應用,我們會發現一個函數總是在一次柯里化調用中接收多個實參 —— 而不是接收一個包含多個值的元組(tuple,類似我們的數組)實參。
在 Haskell 中的示例:
foo 1 2 3
該示例調用了 foo 函數,并且根據傳入的三個值 1、2 和 3 得到了結果。但是在 Haskell 中,函數會自動被柯里化,這意味著我們傳入函數的值都分別傳入了多帶帶的柯里化調用。在 JS 中看起來則會是這樣:foo(1)(2)(3)。這和我此前講過的 curry(..) 風格如出一轍。
注意: 在 Haskell 中,foo (1,2,3) 不是把三個值當作多帶帶的實參一次性傳入函數,而是把它們包含在一個元組(類似 JS 數組)中作為多帶帶實參傳入函數。為了正常運行,我們需要改變 foo 函數來處理作為實參的元組。據我所知,在 Haskell 中我們沒有辦法在一次函數調用中將全部三個實參獨立地傳入,而需要柯里化調用每個函數。誠然,多次調用對于 Haskell 開發者來說是透明的,但對 JS 開發者來說,這在語法上更加一目了然。
基于以上原因,我認為此前展示的 curry(..) 函數是一個對 Haskell 柯里化的可靠改編,我把它叫做 “嚴格柯里化”。
然而,我們需要注意,大多數流行的 JavaScript 函數式編程庫都使用了一種并不嚴格的柯里化(loose currying)定義。
具體來說,往往 JS 柯里化實用函數會允許你在每次柯里化調用中指定多個實參。回顧一下之前提到的 sum(..) 示例,松散柯里化應用會是下面這樣:
var curriedSum = looseCurry( sum, 5 ); curriedSum( 1 )( 2, 3 )( 4, 5 ); // 15
可以看到,語法上我們節省了()的使用,并且把五次函數調用減少成三次,間接提高了性能。除此之外,使用 looseCurry(..) 函數的結果也和之前更加狹義的 curry(..) 函數一樣。我猜便利性和性能因素是眾框架允許多實參柯里化的原因。這看起來更像是品味問題。
注意: 松散柯里化允許你傳入超過形參數量(arity,原函數確認或指定的形參數量)的實參。如果你將函數的參數設計成可配的或變化的,那么松散柯里化將會有利于你。例如,如果你將要柯里化的函數接收 5 個實參,松散柯里化依然允許傳入超過 5 個的實參(curriedSum(1)(2,3,4)(5,6)),而嚴格柯里化就不支持 curriedSum(1)(2)(3)(4)(5)(6)。
我們可以將之前的柯里化實現方式調整一下,使其適應這種常見的更松散的定義:
function looseCurry(fn,arity = fn.length) { return (function nextCurried(prevArgs){ return function curried(...nextArgs){ var args = prevArgs.concat( nextArgs ); if (args.length >= arity) { return fn( ...args ); } else { return nextCurried( args ); } }; })( [] ); }
現在每個柯里化調用可以接收一個或多個實參了(收集在 nextArgs 數組中)。至于這個實用函數的 ES6 箭頭函數版本,我們就留作一個小練習,有興趣的讀者可以模仿之前 curry(..) 函數的來完成。
反柯里化你也會遇到這種情況:拿到一個柯里化后的函數,卻想要它柯里化之前的版本 —— 這本質上就是想將類似 f(1)(2)(3) 的函數變回類似 g(1,2,3) 的函數。
不出意料的話,處理這個需求的標準實用函數通常被叫作 uncurry(..)。下面是簡陋的實現方式:
function uncurry(fn) { return function uncurried(...args){ var ret = fn; for (let i = 0; i < args.length; i++) { ret = ret( args[i] ); } return ret; }; } // ES6 箭頭函數形式 var uncurry = fn => (...args) => { var ret = fn; for (let i = 0; i < args.length; i++) { ret = ret( args[i] ); } return ret; };
警告: 請不要以為 uncurry(curry(f)) 和 f 函數的行為完全一樣。雖然在某些庫中,反柯里化使函數變成和原函數(譯者注:這里的原函數指柯里化之前的函數)類似的函數,但是凡事皆有例外,我們這里就有一個例外。如果你傳入原函數期望數量的實參,那么在反柯里化后,函數的行為(大多數情況下)和原函數相同。然而,如果你少傳了實參,就會得到一個仍然在等待傳入更多實參的部分柯里化函數。我們在下面的代碼中說明這個怪異行為。
function sum(...args) { var sum = 0; for (let i = 0; i < args.length; i++) { sum += args[i]; } return sum; } var curriedSum = curry( sum, 5 ); var uncurriedSum = uncurry( curriedSum ); curriedSum( 1 )( 2 )( 3 )( 4 )( 5 ); // 15 uncurriedSum( 1, 2, 3, 4, 5 ); // 15 uncurriedSum( 1, 2, 3 )( 4 )( 5 ); // 15
uncurry() 函數最為常見的作用對象很可能并不是人為生成的柯里化函數(例如上文所示),而是某些操作所產生的已經被柯里化了的結果函數。我們將在本章后面關于 “無形參風格” 的討論中闡述這種應用場景。
只要一個實參設想你向一個實用函數傳入一個函數,而這個實用函數會把多個實參傳入函數,但可能你只希望你的函數接收單一實參。如果你有個類似我們前面提到被松散柯里化的函數,它能接收多個實參,但你卻想讓它接收單一實參。那么這就是我想說的情況。
我們可以設計一個簡單的實用函數,它包裝一個函數調用,確保被包裝的函數只接收一個實參。既然實際上我們是強制把一個函數處理成單參數函數(unary),那我們索性就這樣命名實用函數:
function unary(fn) { return function onlyOneArg(arg){ return fn( arg ); }; } // ES6 箭頭函數形式 var unary = fn => arg => fn( arg );
我們此前已經和 map(..) 函數打過照面了。它調用傳入其中的 mapping 函數時會傳入三個實參:value、index 和 list。如果你希望你傳入 map(..) 的 mapping 函數只接收一個參數,比如 value,你可以使用 unary(..) 函數來操作:
function unary(fn) { return function onlyOneArg(arg){ return fn( arg ); }; } var adder = looseCurry( sum, 2 ); // 出問題了: [1,2,3,4,5].map( adder( 3 ) ); // ["41,2,3,4,5", "61,2,3,4,5", "81,2,3,4,5", "101, ... // 用 `unary(..)` 修復后: [1,2,3,4,5].map( unary( adder( 3 ) ) ); // [4,5,6,7,8]
另一種常用的 unary(..) 函數調用示例:
["1","2","3"].map( parseFloat ); // [1,2,3] ["1","2","3"].map( parseInt ); // [1,NaN,NaN] ["1","2","3"].map( unary( parseInt ) ); // [1,2,3]
對于 parseInt(str,radix) 這個函數調用,如果 map(..) 函數調用它時在它的第二個實參位置傳入 index,那么毫無疑問 parseInt(..) 會將 index 理解為 radix 參數,這是我們不希望發生的。而 unary(..) 函數創建了一個只接收第一個傳入實參,忽略其他實參的新函數,這就意味著傳入 index 不再會被誤解為 radix 參數。
傳一個返回一個說到只傳一個實參的函數,在函數式編程工具庫中有另一種通用的基礎函數:該函數接收一個實參,然后什么都不做,原封不動地返回實參值。
function identity(v) { return v; } // ES6 箭頭函數形式 var identity = v => v;
看起來這個實用函數簡單到了無處可用的地步。但即使是簡單的函數在函數式編程的世界里也能發揮作用。就像演藝圈有句諺語:沒有小角色,只有小演員。
舉個例子,想象一下你要用正則表達式拆分(split up)一個字符串,但輸出的數組中可能包含一些空值。我們可以使用 filter(..) 數組方法(下文會詳細說到這個方法)來篩除空值,而我們將 identity(..) 函數作為 filter(..) 的斷言:
var words = " Now is the time for all... ".split( /s|/ ); words; // ["","Now","is","the","time","for","all","...",""] words.filter( identity ); // ["Now","is","the","time","for","all","..."]
既然 identity(..) 會簡單地返回傳入的值,而 JS 會將每個值強制轉換為 true 或 false,這樣我們就能在最終的數組里對每個值進行保存或排除。
小貼士: 像這個例子一樣,另外一個能被用作斷言的單實參函數是 JS 自有的 Boolean(..) 方法,該方法會強制把傳入值轉為 true 或 false。
另一個使用 identity(..) 的示例就是將其作為替代一個轉換函數(譯者注:transformation,這里指的是對傳入值進行修改或調整,返回新值的函數)的默認函數:
function output(msg,formatFn = identity) { msg = formatFn( msg ); console.log( msg ); } function upper(txt) { return txt.toUpperCase(); } output( "Hello World", upper ); // HELLO WORLD output( "Hello World" ); // Hello World
如果不給 output(..) 函數的 formatFn 參數設置默認值,我們可以叫出老朋友 partialRight(..) 函數:
var specialOutput = partialRight( output, upper ); var simpleOutput = partialRight( output, identity ); specialOutput( "Hello World" ); // HELLO WORLD simpleOutput( "Hello World" ); // Hello World
你也可能會看到 identity(..) 被當作 map(..) 函數調用的默認轉換函數,或者作為某個函數數組的 reduce(..) 函數的初始值。我們將會在第 8 章中提到這兩個實用函數。
恒定參數Certain API 禁止直接給方法傳值,而要求我們傳入一個函數,就算這個函數只是返回一個值。JS Promise 中的 then(..) 方法就是一個 Certain API。很多人聲稱 ES6 箭頭函數可以當作這個問題的 “解決方案”。但我這有一個函數式編程實用函數可以完美勝任該任務:
function constant(v) { return function value(){ return v; }; } // or the ES6 => form var constant = v => () => v;
這個微小而簡潔的實用函數可以解決我們關于 then(..) 的煩惱:
p1.then( foo ).then( () => p2 ).then( bar ); // 對比: p1.then( foo ).then( constant( p2 ) ).then( bar );
警告: 盡管使用 () => p2 箭頭函數的版本比使用 constant(p2) 的版本更簡短,但我建議你忍住別用前者。該箭頭函數返回了一個來自外作用域的值,這和 函數式編程的理念有些矛盾。我們將會在后面第 5 章的 “減少副作用” 小節中提到這種行為帶來的陷阱。
擴展在參數中的妙用在第 2 章中,我們簡要地講到了形參數組解構。回顧一下該示例:
function foo( [x,y,...args] ) { // .. } foo( [1,2,3] );
在 foo(..) 函數的形參列表中,我們期望接收單一數組實參,我們要把這個數組拆解 —— 或者更貼切地說,擴展(spread out)—— 成獨立的實參 x 和 y。除了頭兩個位置以外的參數值我們都會通過 ... 操作將它們收集在 args 數組中。
當函數必須接收一個數組,而你卻想把數組內容當成多帶帶形參來處理的時候,這個技巧十分有用。
然而,有的時候,你無法改變原函數的定義,但想使用形參數組解構。舉個例子,請思考下面的函數:
function foo(x,y) { console.log( x + y ); } function bar(fn) { fn( [ 3, 9 ] ); } bar( foo ); // 失敗
你注意到為什么 bar(foo) 函數失敗了嗎?
我們將 [3,9] 數組作為單一值傳入 fn(..) 函數,但 foo(..) 期望接收多帶帶的 x 和 y 形參。如果我們可以把 foo(..) 的函數聲明改變成 function foo([x,y]) { .. 那就好辦了。或者,我們可以改變 bar(..) 函數的行為,把調用改成 fn(...[3,9]),這樣就能將 3 和 9 分別傳入 foo(..) 函數了。
假設有兩個在此方法上互不兼容的函數,而且由于各種原因你無法改變它們的聲明和定義。那么你該如何一并使用它們呢?
為了調整一個函數,讓它能把接收的單一數組擴展成各自獨立的實參,我們可以定義一個輔助函數:
function spreadArgs(fn) { return function spreadFn(argsArr) { return fn( ...argsArr ); }; } // ES6 箭頭函數的形式: var spreadArgs = fn => argsArr => fn( ...argsArr );
注意: 我把這個輔助函數叫做 spreadArgs(..),但一些庫,比如 Ramda,經常把它叫做 apply(..)。
現在我們可以使用 spreadArgs(..) 來調整 foo(..) 函數,使其作為一個合適的輸入參數并正常地工作:
bar( spreadArgs( foo ) ); // 12
相信我,雖然我不能講清楚這些問題出現的原因,但它們一定會出現的。本質上,spreadArgs(..) 函數使我們能夠定義一個借助數組 return 多個值的函數,不過,它讓這些值仍然能分別作為其他函數的輸入參數來處理。
一個函數的輸出作為另外一個函數的輸入被稱作組合(composition),我們將在第四章詳細討論這個話題。
盡管我們在談論 spreadArgs(..) 實用函數,但我們也可以定義一下實現相反功能的實用函數:
function gatherArgs(fn) { return function gatheredFn(...argsArr) { return fn( argsArr ); }; } // ES6 箭頭函數形式 var gatherArgs = fn => (...argsArr) => fn( argsArr );
注意: 在 Ramda 中,該實用函數被稱作 unapply(..),是與 apply(..) 功能相反的函數。我認為術語 “擴展(spread)” 和 “聚集(gather)” 可以把這兩個函數發生的事情解釋得更好一些。
因為有時我們可能要調整一個函數,解構其數組形參,使其成為另一個分別接收多帶帶實參的函數,所以我們可以通過使用 gatherArgs(..) 實用函數來將多帶帶的實參聚集到一個數組中。我們將在第 8 章中細說 reduce(..) 函數,這里我們簡要說一下:它重復調用傳入的 reducer 函數,其中 reducer 函數有兩個形參,現在我們可以將這兩個形參聚集起來:
function combineFirstTwo([ v1, v2 ]) { return v1 + v2; } [1,2,3,4,5].reduce( gatherArgs( combineFirstTwo ) ); // 15參數順序的那些事兒
對于多形參函數的柯里化和偏應用,我們不得不通過許多令人懊惱的技巧來修正這些形參的順序。有時我們把一個函數的形參順序定義成柯里化需求的形參順序,但這種順序沒有兼容性,我們不得不絞盡腦汁來重新調整它。
讓人沮喪的可不僅是我們需要使用實用函數來委曲求全,在此之外,這種做法還會導致我們的代碼被無關代碼混淆。這種東西就像碎紙片,這一片那一片的,而不是一整個突出問題,但這些問題的細碎絲毫不會減少它們帶來的苦惱。
難道就沒有能讓我們從修正參數順序這件事里解脫出來的方法嗎!?
在第 2 章里,我們講到了命名實參(named-argument)解構模式。回顧一下:
function foo( {x,y} = {} ) { console.log( x, y ); } foo( { y: 3 } ); // undefined 3
我們將 foo(..) 函數的第一個形參 —— 它被期望是一個對象 —— 解構成多帶帶的形參 x 和 y。接著在調用時傳入一個對象實參,并且提供函數期望的屬性,這樣就可以把 “命名實參” 映射到相應形參上。
命名實參主要的好處就是不用再糾結實參傳入的順序,因此提高了可讀性。我們可以發掘一下看看是否能設計一個等效的實用函數來處理對象屬性,以此提高柯里化和偏應用的可讀性:
function partialProps(fn,presetArgsObj) { return function partiallyApplied(laterArgsObj){ return fn( Object.assign( {}, presetArgsObj, laterArgsObj ) ); }; } function curryProps(fn,arity = 1) { return (function nextCurried(prevArgsObj){ return function curried(nextArgObj = {}){ var [key] = Object.keys( nextArgObj ); var allArgsObj = Object.assign( {}, prevArgsObj, { [key]: nextArgObj[key] } ); if (Object.keys( allArgsObj ).length >= arity) { return fn( allArgsObj ); } else { return nextCurried( allArgsObj ); } }; })( {} ); }
我們甚至不需要設計一個 partialPropsRight(..) 函數了,因為我們根本不需要考慮屬性的映射順序,通過命名來映射形參完全解決了我們有關于順序的煩惱!
我們這樣使用這些使用函數:
function foo({ x, y, z } = {}) { console.log( `x:${x} y:${y} z:${z}` ); } var f1 = curryProps( foo, 3 ); var f2 = partialProps( foo, { y: 2 } ); f1( {y: 2} )( {x: 1} )( {z: 3} ); // x:1 y:2 z:3 f2( { z: 3, x: 1 } ); // x:1 y:2 z:3
我們不用再為參數順序而煩惱了!現在,我們可以指定我們想傳入的實參,而不用管它們的順序如何。再也不需要類似 reverseArgs(..) 的函數或其它妥協了。贊!
屬性擴展不幸的是,只有在我們可以掌控 foo(..) 的函數簽名,并且可以定義該函數的行為,使其解構第一個參數的時候,以上技術才能起作用。如果一個函數,其形參是各自獨立的(沒有經過形參解構),而且不能改變它的函數簽名,那我們應該如何運用這個技術呢?
function bar(x,y,z) { console.log( `x:${x} y:${y} z:${z}` ); }
就像之前的 spreadArgs(..) 實用函數一樣,我們也可以定義一個 spreadArgProps(..) 輔助函數,它接收對象實參的 key: value 鍵值對,并將其 “擴展” 成獨立實參。
不過,我們需要注意某些異常的地方。我們使用 spreadArgs(..) 函數處理數組實參時,參數的順序是明確的。然而,對象屬性的順序是不太明確且不可靠的。取決于不同對象的創建方式和屬性設置方式,我們無法完全確認對象會產生什么順序的屬性枚舉。
針對這個問題,我們定義的實用函數需要讓你能夠指定函數期望的實參順序(比如屬性枚舉的順序)。我們可以傳入一個類似 ["x","y","z"] 的數組,通知實用函數基于該數組的順序來獲取對象實參的屬性值。
這著實不錯,但還是有點瑕疵,就算是最簡單的函數,我們也免不了為其增添一個由屬性名構成的數組。難道我們就沒有一種可以探知函數形參順序的技巧嗎?哪怕給一個普通而簡單的例子?還真有!
JavaScript 的函數對象上有一個 .toString() 方法,它返回函數代碼的字符串形式,其中包括函數聲明的簽名。先忽略其正則表達式分析技巧,我們可以通過解析函數字符串來獲取每個多帶帶的命名形參。雖然這段代碼看起來有些粗暴,但它足以滿足我們的需求:
function spreadArgProps( fn, propOrder = fn.toString() .replace( /^(?:(?:function.*(([^]*?)))|(?:([^()]+?)s*=>)|(?:(([^]*?))s*=>))[^]+$/, "$1$2$3" ) .split( /s*,s*/ ) .map( v => v.replace( /[=s].*$/, "" ) ) ) { return function spreadFn(argsObj) { return fn( ...propOrder.map( k => argsObj[k] ) ); }; }
注意: 該實用函數的參數解析邏輯并非無懈可擊,使用正則來解析代碼這個前提就已經很不靠譜了!但處理一般情況是我們的唯一目標,從這點來看這個實用函數還是恰到好處的。我們需要的只是對簡單形參(包括帶默認值的形參)函數的形參順序做一個恰當的默認檢測。例如,我們的實用函數不需要把復雜的解構形參給解析出來,因為無論如何我們不太可能對擁有這種復雜形參的函數使用 spreadArgProps() 函數。因此該邏輯能搞定 80% 的需求,它允許我們在其它不能正確解析復雜函數簽名的情況下覆蓋 propOrder 數組形參。這是本書盡可能尋找的一種實用性平衡。
讓我們看看 spreadArgProps(..) 實用函數是怎么用的:
function bar(x,y,z) { console.log( `x:${x} y:${y} z:${z}` ); } var f3 = curryProps( spreadArgProps( bar ), 3 ); var f4 = partialProps( spreadArgProps( bar ), { y: 2 } ); f3( {y: 2} )( {x: 1} )( {z: 3} ); // x:1 y:2 z:3 f4( { z: 3, x: 1 } ); // x:1 y:2 z:3
提個醒:本文中呈現的對象形參(object parameters)和命名實參(named arguments)模式,通過減少由調整實參順序帶來的干擾,明顯地提高了代碼的可讀性,不過據我所知,沒有哪個主流的函數式編程庫使用該方案。所以你會看到該做法與大多數 JavaScript 函數式編程很不一樣.
此外,使用在這種風格下定義的函數要求你知道每個實參的名字。你必須記住:“這個函數形參叫作 ‘fn’ ”,而不是只記得:“噢,把這個函數作為第一個實參傳進去”。
請小心地權衡它們。
無形參風格在函數式編程的世界中,有一種流行的代碼風格,其目的是通過移除不必要的形參-實參映射來減少視覺上的干擾。這種風格的正式名稱為 “隱性編程(tacit programming)”,一般則稱作 “無形參(point-free)” 風格。術語 “point” 在這里指的是函數形參。
警告: 且慢,先說明我們這次的討論是一個有邊界的提議,我不建議你在函數式編程的代碼里不惜代價地濫用無形參風格。該技術是用于在適當情況下提升可讀性。但你完全可能像濫用軟件開發里大多數東西一樣濫用它。如果你由于必須遷移到無參數風格而讓代碼難以理解,請打住。你不會因此獲得小紅花,因為你用看似聰明但晦澀難懂的方式抹除形參這個點的同時,還抹除了代碼的重點。
我們從一個簡單的例子開始:
function double(x) { return x * 2; } [1,2,3,4,5].map( function mapper(v){ return double( v ); } ); // [2,4,6,8,10]
可以看到 mapper(..) 函數和 double(..) 函數有相同(或相互兼容)的函數簽名。形參(也就是所謂的 “point“)v 可以直接映射到 double(..) 函數調用里相應的實參上。這樣,mapper(..) 函數包裝層是非必需的。我們可以將其簡化為無形參風格:
function double(x) { return x * 2; } [1,2,3,4,5].map( double ); // [2,4,6,8,10]
回顧之前的一個例子:
["1","2","3"].map( function mapper(v){ return parseInt( v ); } ); // [1,2,3]
該例中,mapper(..) 實際上起著重要作用,它排除了 map(..) 函數傳入的 index 實參,因為如果不這么做的話,parseInt(..) 函數會錯把 index 當作 radix 來進行整數解析。該例子中我們可以借助 unary(..) 函數:
["1","2","3"].map( unary( parseInt ) ); // [1,2,3]
使用無形參風格的關鍵,是找到你代碼中,有哪些地方的函數直接將其形參作為內部函數調用的實參。以上提到的兩個例子中,mapper(..) 函數拿到形參 v 多帶帶傳入了另一個函數調用。我們可以借助 unary(..) 函數將提取形參的邏輯層替換成無參數形式表達式。
警告: 你可能跟我一樣,已經嘗試著使用 map(partialRight(parseInt,10)) 來將 10 右偏應用為 parseInt(..) 的 radix 實參。然而,就像我們之前看到的那樣,partialRight(..) 僅僅保證將 10 當作最后一個實參傳入原函數,而不是將其指定為第二個實參。因為 map(..) 函數本身會將 3 個實參(value、index 和 arr)傳入它的映射函數,所以 10 就會被當成第四個實參傳入 parseInt(..) 函數,而這個函數只會對頭兩個實參作出反應。
來看另一個例子:
// 將 `console.log` 當成一個函數使用 // 便于避免潛在的綁定問題 function output(txt) { console.log( txt ); } function printIf( predicate, msg ) { if (predicate( msg )) { output( msg ); } } function isShortEnough(str) { return str.length <= 5; } var msg1 = "Hello"; var msg2 = msg1 + " World"; printIf( isShortEnough, msg1 ); // Hello printIf( isShortEnough, msg2 );
現在,我們要求當信息足夠長時,將它打印出來,換而言之,我們需要一個 !isShortEnough(..) 斷言。你可能會首先想到:
function isLongEnough(str) { return !isShortEnough( str ); } printIf( isLongEnough, msg1 ); printIf( isLongEnough, msg2 ); // Hello World
這太簡單了...但現在我們的重點來了!你看到了 str 形參是如何傳遞的嗎?我們能否不通過重新實現 str.length 的檢查邏輯,而重構代碼并使其變成無形參風格呢?
我們定義一個 not(..) 取反輔助函數(在函數式編程庫中又被稱作 complement(..)):
function not(predicate) { return function negated(...args){ return !predicate( ...args ); }; } // ES6 箭頭函數形式 var not = predicate => (...args) => !predicate( ...args );
接著,我們使用 not(..) 函數來定義無形參的 isLongEnough(..) 函數:
var isLongEnough = not( isShortEnough ); printIf( isLongEnough, msg2 ); // Hello World
目前為止已經不錯了,但還能更進一步。我們實際上可以將 printIf(..) 函數本身重構成無形參風格。
我們可以用 when(..) 實用函數來表示 if 條件句:
function when(predicate,fn) { return function conditional(...args){ if (predicate( ...args )) { return fn( ...args ); } }; } // ES6 箭頭函數形式 var when = (predicate,fn) => (...args) => predicate( ...args ) ? fn( ...args ) : undefined;
我們把本章前面講到的另一些輔助函數和 when(..) 函數結合起來搞定無形參風格的 printIf(..) 函數:
var printIf = uncurry( rightPartial( when, output ) );
我們是這么做的:將 output 方法右偏應用為 when(..) 函數的第二個(fn 形參)實參,這樣我們得到了一個仍然期望接收第一個實參(predicate 形參)的函數。當該函數被調用時,會產生另一個期望接收(譯者注:需要被打印的)信息字符串的函數,看起來就是這樣:fn(predicate)(str)。
多個(兩個)鏈式函數的調用看起來很挫,就像被柯里化的函數。于是我們用 uncurry(..) 函數處理它,得到一個期望接收 str 和 predicate 兩個實參的函數,這樣該函數的簽名就和 printIf(predicate,str) 原函數一樣了。
我們把整個例子復盤一下(假設我們本章已經講解的實用函數都在這里了):
function output(msg) { console.log( msg ); } function isShortEnough(str) { return str.length <= 5; } var isLongEnough = not( isShortEnough ); var printIf = uncurry( partialRight( when, output ) ); var msg1 = "Hello"; var msg2 = msg1 + " World"; printIf( isShortEnough, msg1 ); // Hello printIf( isShortEnough, msg2 ); printIf( isLongEnough, msg1 ); printIf( isLongEnough, msg2 ); // Hello World
但愿無形參風格編程的函數式編程實踐逐漸變得更有意義。你仍然可以通過大量實踐來訓練自己,讓自己接受這種風格。再次提醒,請三思而后行,掂量一下是否值得使用無形參風格編程,以及使用到什么程度會益于提高代碼的可讀性。
有形參還是無形參,你怎么選?
注意: 還有什么無形參風格編程的實踐呢?我們將在第 4 章的 “回顧形參” 小節里,站在新學習的組合函數知識之上來回顧這個技術。
總結偏應用是用來減少函數的參數數量 —— 一個函數期望接收的實參數量 —— 的技術,它減少參數數量的方式是創建一個預設了部分實參的新函數。
柯里化是偏應用的一種特殊形式,其參數數量降低為 1,這種形式包含一串連續的鏈式函數調用,每個調用接收一個實參。當這些鏈式調用指定了所有實參時,原函數就會拿到收集好的實參并執行。你同樣可以將柯里化還原。
其它類似 unary(..)、identity(..) 以及 constant(..) 的重要函數操作,是函數式編程基礎工具庫的一部分。
無形參是一種書寫代碼的風格,這種風格移除了非必需的形參映射實參邏輯,其目的在于提高代碼的可讀性和可理解性。
【上一章】翻譯連載 |《JavaScript 輕量級函數式編程》- 第 2 章:函數基礎
【下一章】翻譯連載 |《你不知道的JS》姊妹篇 |《JavaScript 輕量級函數式編程》- 第4章:組合函數
iKcamp原創新書《移動Web前端高效開發實戰》已在亞馬遜、京東、當當開售。
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/85184.html
摘要:我稱之為輕量級函數式編程。序眾所周知,我是一個函數式編程迷。函數式編程有很多種定義。本書是你開啟函數式編程旅途的絕佳起點。事實上,已經有很多從頭到尾正確的方式介紹函數式編程的書了。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson - 《You-Dont-Know-JS》作者 譯者團隊(排名不分先后):阿希、blueken、brucecham、...
摘要:一旦我們滿足了基本條件值為,我們將不再調用遞歸函數,只是有效地執行了。遞歸深諳函數式編程之精髓,最被廣泛引證的原因是,在調用棧中,遞歸把大部分顯式狀態跟蹤換為了隱式狀態。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson-《You-Dont-Know-JS》作者 關于譯者:這是一個流淌著滬江血液的純粹工程:認真,是 HTML 最堅實的梁柱;...
摘要:所以我覺得函數式編程領域更像學者的領域。函數式編程的原則是完善的,經過了深入的研究和審查,并且可以被驗證。函數式編程是編寫可讀代碼的最有效工具之一可能還有其他。我知道很多函數式編程編程者會認為形式主義本身有助于學習。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson - 《You-Dont-Know-JS》作者 關于譯者:這是一個流淌著滬江血液...
摘要:這就是積極的函數式編程。上一章翻譯連載第章遞歸下輕量級函數式編程你不知道的姊妹篇原創新書移動前端高效開發實戰已在亞馬遜京東當當開售。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson-《You-Dont-Know-JS》作者 關于譯者:這是一個流淌著滬江血液的純粹工程:認真,是 HTML 最堅實的梁柱;分享,是 CSS 里最閃耀的一瞥;總...
摘要:從某些方面來講,這章回顧的函數知識并不是針對函數式編程者,非函數式編程者同樣需要了解。什么是函數針對函數式編程,很自然而然的我會想到從函數開始。如果你計劃使用函數式編程,你應該盡可能多地使用函數,而不是程序。指的是一個函數聲明的形參數量。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson - 《You-Dont-Know-JS》作者 關于譯者:...
閱讀 1864·2021-11-25 09:43
閱讀 2146·2021-11-19 09:40
閱讀 3422·2021-11-18 13:12
閱讀 1739·2021-09-29 09:35
閱讀 661·2021-08-24 10:00
閱讀 2505·2019-08-30 15:55
閱讀 1709·2019-08-30 12:56
閱讀 1815·2019-08-28 17:59