函數語言程式設計之柯里化與偏應用
在本章中,我們將瞭解術語柯里化的含義,在瞭解了柯里化所做的事情及用途之後,我們將介紹另一個在函數語言程式設計中稱為偏應用的概念。
我們將研究一個簡單的問題,並說明柯里化與偏應用這類函式式技術的執行機制
6.1 一些術語
先來了解一些術語
- 一元函式:只接受一個引數的函式稱為一元函式
- 二元函式:接受兩個引數的函式稱為二元函式
- 變參函式:變參函式是接受可變數量引數的函式,我們可以用 arguments 來捕捉引數
ES6 新增了擴充套件運算子,我們可以用它來模擬變參函式
function fn(a,...rest){ console.log(a) console.log(rest) } fn(1,2,3,4,5) // 1 // 2,3,4,5 複製程式碼
6.2 柯里化
相信這個概念你已經看過或者聽過了,那麼什麼是柯里化呢?
柯里化是把一個多引數函式轉換為一個巢狀的一元函式 的過程
我們通過一個簡單的例子來看一下
const add = (x, y) => x + y; 複製程式碼
這是一個簡單的函式,我們將它柯里化
const addCurried = x => y => x + y; 複製程式碼
上面的 addCurried 函式式 add 的一個柯里化版本,如果我們用一個單一的引數呼叫 addCurried
addCurried(4)
它返回一個函式,在其中 x 值通過閉包被捕獲,可能這樣看得不太清楚,那我們改成 ES5 的語法
const addCurried = function(x){ return function(y){ return x + y; } } // 使用方法 addCurried(4)(4) // 8 複製程式碼
此處我們手動地把接受兩個引數的 add 函式轉換為含有巢狀的一元函式的 addCurried 函式。下面展示瞭如何把該處理過程轉換為一個名為 curry 的方法
const curry = binaryFn => { return function(firstArg){ return function(secondArg){ return binaryFn(firstArg,secondArg); } } } // 使用方法 let autoCurriedAdd = curry(add) addCurriedAdd(2)(2) // 4 複製程式碼
現在我們回顧柯里化的定義
柯里化是把一個多引數函式轉換為一個巢狀的一元函式的過程
那麼問題就來了,我們要柯里化幹什麼?
6.2.1 柯里化用例
我們先從一個簡單的例子開始。
假設我們要編寫一個建立列表的函式。例如,我們需要建立 tableOf2、tableOf3、tableOf4 等
可以通過如下程式碼實現
const tableOf2 = y => 2 * y; const tableOf3 = y => 3 * y; const tableOf4 = y => 4 * y; // 使用方法如下 tableOf2(4) // 8 tableOf3(4) // 12 tableOf4(4) // 16 複製程式碼
可以把這些表格的概念概括為一個單獨的函式
const genericTable = (x, y) => x * y
所以我們可以通過 curry 使用 genericTable 來構建表格
const tableOf2 = curry(genericTable)(2) const tableOf3 = curry(genericTable)(3) const tableOf4 = curry(genericTable)(4) 複製程式碼
6.2.2 日誌函式——應用柯里化
上一節展示了柯里化能做什麼。本節將使用一個複雜點的例子。比如開發者編寫程式碼的時候會在應用的不同階段編寫很多日誌。我們可以編寫一個如下的日誌函式
const loggerHelper = (mode,initialMessage,errorMessage,lineNo) => { if(mode === "DEBUG"){ console.debug(initialMessage,errorMessage + "at line:" + lineNo) }else if(mode === "ERROR"){ console.error(initialMessage,errorMessage + "at line:" + lineNo) }else if(mode === "WARN"){ console.warn(initialMessage,errorMessage + "at line:" + lineNo) }else{ throw "Wrong mode" } } 複製程式碼
當團隊中的任何開發者需要向控制檯列印 Stats.js 檔案中的錯誤時,可以用如下方式使用函式
loggerHelper("ERROR","Error At Stats.js","Invalid argument passed",23) loggerHelper("ERROR","Error At Stats.js","undefined argument",223) loggerHelper("ERROR","Error At Stats.js","curry function is not defined",3) loggerHelper("ERROR","Error At Stats.js","slice is not defined",31) 複製程式碼
在這個函式裡面,前兩個引數都是一樣的,那麼能像剛剛一樣使用 curry 函式嗎?很可惜不能,因為上一節定義的函式只能接受兩個引數,而這裡是 4 個引數。
下面我們解決這個問題並實現 curry 函式的完整功能,讓它能夠處理多個引數
6.2.3 完整的 curry 函式
我們回到柯里化的定義,將多引數函式轉換為巢狀的一元函式
我們先做一些判斷,當用戶輸入不是函式時報錯
let curry = fn =>{ if(typeof fn !== 'function'){ throw Error('No function provided') } return function curriedFn(...args){ if(args.length < fn.length){ return function(){ return curriedFn.apply(null,args.concat([...arguments])); } } return fn.apply(null, args) } } 複製程式碼
我們來逐步分析這段程式碼發生了什麼
首先,我們判斷如果使用者傳的不是函式就報錯
然後返回一個函式,接受多個引數
判斷傳入引數的長度是否小於函式引數列表的長度。如果是就進入 if 程式碼塊,如果不是,就呼叫傳入的函式
現在來看 if 程式碼塊裡面
我們先判斷 args 的長度和 fn 引數的長度是否一致,如果大於等於則直接呼叫函式。如果小於的話則遞迴呼叫 curriedFn。注意使用 concat 把引數連線起來,當引數大於或等於的時候呼叫原函式。直接看個例子吧
const multiply = (x, y, z) => x * y * z let curriedMul1 = curry(multiply)(1,2,3) // 6 // 這個 args 是三個,等於 multiply 的引數長度,所以直接返回結果 let curriedMul2 = curry(multiply)(2,3) // 這個 args 是兩個,小於引數長度,所以返回的還是一個函式,這個函式現在有兩個引數,當再傳入一個引數(也可以傳入多個)時,就會執行該函式,例如 curriedMul2(4) // 24 curriedMul2(4,5,6) // 24 let curriedMul3 = curry(multiply)(2) // 這個 args 是兩個,小於引數長度,所以返回的還是一個函式,這個函式現在有一個引數,當再傳入一個引數時,還是會返回一個函式,就變成第二種情況 curriedMul3(3) // 返回的還是一個函式,變成第二種情況 curriedMul3(3,4) // 24 curriedMul3(3)(4) // 24 複製程式碼
現在 curry 函式可以把一個多引數函式轉化為一個一元函數了
6.2.4 回顧日誌函式
現在我們可以用 curry 函式重寫這個函數了,下面通過 curry 解決重複使用前兩個引數的問題
let errorLogger = curry(loggerHelper)("ERROR")("Error At Stats.js") let debugLogger = curry(loggerHelper)("DEBUG")("Debug At Stats.js") let warnLogger = curry(loggerHelper)("WARN")("Warn At Stats.js") 複製程式碼
現在我們能夠輕鬆使用上面的柯里化函式並在各自的上下文中使用它們了
// 用於錯誤 errorLogger("Error message",21) // Error At Stats.js Error message at line:21 // 用於除錯 debugLogger("Debug message",223) // Debug At Stats.js Debug message at line:223 // 用於警告 warnLogger("Warn message",34) // Warn At Stats.js Warn message at line:223 複製程式碼
這太棒了,curry 函式有助於移除很多函式呼叫中的樣板程式碼
6.3 柯里化實戰
在上一節中我們看到了使用 curry 函式的簡單示例,在本節這種,我們將看到柯里化技術在小巧而簡潔的示例中的應用。本節中的示例將讓你在日常工作中如何使用柯里化有更好的理解
假設我們要查詢含有數字的陣列內容,可以通過如下方式解決
let match = curry(function(expr,str){ return str.match(expr) }) 複製程式碼
返回的 match 函式是一個柯里化函式。我們可以給第一個引數 expr 一個正則表示式 /\d+/,這將表明內容中是否含有數字
let hasNumber = match(/\d+/)
現在我們建立一個柯里化的 filter 函式
let filter = curry(function(f, ary){ return ary.filter(f); }) 複製程式碼
通過 hasNumber 和 filter 我們就可以建立一個新的名為 findNumbersInArray 的函式
let findNumbersInArray = filter(hasNumber) // 使用 finNumbersInArray(['js','number1']) // ['number1'] 複製程式碼
大功告成
6.4 偏應用
可能這一章有人就看不下去了,因為我也是,但是其實照做下來的話會發現沒有那麼難,也不是特別難理解,不要被嚇倒!!!
什麼是偏應用呢?我們先看這麼一個功能
假設我們要 10ms 後做一組操作,可以通過 setTimeout 來實現
setTimeout(()=>console.log('do something'),10) setTimeout(()=>console.log('do anotherthing'),10) 複製程式碼
這個函式可以柯里化嗎?答案是否定的,因為 curry 的引數列表是從左往右的。一個變通的方案是這樣
const setTimeoutWrapper = (time,fn)=>{ setTimeout(fn,time) } const delayTenMs = curry(setTimeoutWrapper)(10) delayTenMs(()=>console.log('do something')) delayTenMs(()=>console.log('do anotherthing')) 複製程式碼
但是這樣的話我們需要建立一個包裹函式,這也是一種開銷,這裡就可以使用偏應用技術
6.4.1 實現偏函式
為了全面理解偏應用技術的機制,我們將先建立一個偏(partial)函式。實現以後,我們將通過一個簡單的例子學習如何使用偏函式(不要覺得難,自己跟著做一下,拿個例子試下就很清楚了)
const partial = function(fn,...partialArgs){ let args = partialArgs return function(...fullArguments){ let arg = 0; for(let i = 0; i < args.length && arg < fullArguments.length; i++){ if(args[i] === undefined){ args[i] = fullArguments[i]; } } return fn.apply(null,args); } } 複製程式碼
可能有些看不懂,來用個例子慢慢解釋一下
const delayTenMs = partial(setTimeout,undefined,10) 複製程式碼
現在 args 就是 [undefined,10],然後返回的是一個函式,所以 delayTenMs 其實是如下形式
function delayTenMs(...fullArguments){ let arg = 0; // 其中 args 是 [undefined,10] for(let i = 0; i < args.length && arg < fullArguments.length; i++){ if(args[i] === undefined){ args[i] = fullArguments[i]; } } // fn 是 setTimeout return fn.apply(null,args); } 複製程式碼
然後,我們來使用 delayTenMs
delayTenMs(()=>console.log('do something')) // 10ms 後將打印出 do something,說明功能已經實現 複製程式碼
我們來看看為什麼會這樣
// 現在的 delayTenMs 函式是這樣 function delayTenMs(...fullArguments){ let arg = 0; // 其中 args 是 [undefined,10] for(let i = 0; i < args.length && arg < fullArguments.length; i++){ if(args[i] === undefined){ args[i] = fullArguments[arg++]; } } // fn 是 setTimeout return fn.apply(null,args); } // 執行 delayTenMs(()=>console.log('do something')) 複製程式碼
這時候fullArguments
就變成了一個只有一個引數的陣列,即[()=>console.log('do something')]
,因為陣列的每一項可以為任意型別,所以這裡就是隻有一個函式的陣列
args.length
等於 2,fullArguments.length
等於 1,所以進入迴圈
args[0]===undefined
滿足條件,所以把args[0]
變為fullArguments[0]
即args[0] = ()=>console.log('do something')
,這時args = [()=>console.log('do something'),10]
,最後執行 fn 即 setTimeout,並把 args 作為引數傳進去,即得到我們想要的結果,怎麼樣,是不是沒有想象的那麼難?
好了,接下來我們再看一個例子,用JSON.stringify
來格式化輸出
let obj = {foo:'bar', bar:'foo'} JSON.stringify(obj, null, 2) // JSON.stringify 接收三個引數,第二個是一個函式或陣列,感興趣的可以自己查一下。第三個是縮排的字元數 複製程式碼
由於後兩個引數是固定的,所以我們可以用 partial 來移除它們(最好自己想一下再看)
let prettyPrintJson = partial(JSON.stringify,undefined,null,2); // 使用方法 prettyPrintJson(obj); /* '{ "foo":"bar", "bar":"foo" }' */ 複製程式碼
這個程式其實有個 bug,如果你用一個不同的引數再次呼叫的時候還是給出第一次的結果,為什麼呢?因為 args 是陣列,傳的是引用,而不是一個函式。所以這裡第二次呼叫的時候 args[0] 已經不等於 undefined 了。所以不會修改這個值。
6.4.2 柯里化與偏應用
那麼我們什麼時候用柯里化,什麼時候用偏應用呢?取決於 API 是如何定義的。如果 API 如 map、filter 一樣定義,我們就可以輕鬆的使用 curry。而如果不是這樣的話,可能選擇偏應用更合適一些。
6.5 小結
今天主要學習了柯里化與偏應用這兩個函數語言程式設計中經常見到的詞,也建立了對應的函式,並瞭解了它們的一般用途。柯里化和偏應用主要是對於引數進行一些操作,將多個引數轉換為單一引數。如果引數不足的話它們就會處在一種中間狀態,我們可以利用這種中間狀態做任何事!!!
還記得剛開始我們說函數語言程式設計組合的特點嗎,這就是我們明天要學習的內容:組合一些小函式來構建一個新的函式。明天見