JavaScript
Interview Preparation
此Curry非彼Curry,不過Naan沾咖哩真的好好吃啊
Image Not Showing Possible ReasonsLearn More →
- The image file may be corrupted
- The server hosting the image is unavailable
- The image path is incorrect
- The image format is not supported
柯里化(Currying)是functional programming的一種技術 ,透過currying可以編寫模組化、易於測試和高度可重複使用的程式碼。
Functional programming(FP)是一種宣告式規範( declarative paradigm),強調不變性(immutability)和純函式(Pure Functions)—代表該函式對於任何給定的輸入(input),永遠會回傳相同的輸出(output)。這些特性可以使程式碼更易讀並且更容易維護,而Currying只是其中的一種技術。
更多關於Functional Programming,可以閱讀以下文章:
Currying可以將具有多個參數的函式轉換為一系列巢狀(nesting)函式。也就是說,函式並非一次接受所有參數,而是接受第一個參數並返回一個新函式,此新函式接受第二個參數並返回另一個新函式,此新函式再接受第三個參數,依此類推,直到所有參數都被執行完畢。
舉例來說,以下函式尚未進行currying:
如果只傳入一個參數a
,此函式仍會執行,而參數b
則會以undefined
去執行,也就是說如果執行multiply(5)
,實際上執行的是5 * undefined
,回傳NaN
。
現在試著將函式currying,轉換成一系列巢狀函式:
調用curried_multiply()
函式:
curried_multiply()
函式,這個函式接受一個參數傳入,並且回傳另一個函式nested()
。也就是調用curried_multiply(a)
,回傳的結果是nested(b)
。nested()
函式,這個函式使用調用curried_multiply()
以及nested()
函式得到的參數a
、b
,並執行與回傳a * b
的結果。調用nested(b)
,回傳的結果是a * b
。如果進一步拆解這個函式:
回傳的值是nested()
函式,接著呼叫one()
函式,實際上是執行nested()
函式:
回傳的是 a * b
,也就是 5 * 10
的值。
呼叫curried_multiply()
函式時調用的參數可用於巢狀函式,這是閉包(closure)的特性。函式中回傳函式,通常就是閉包。 當呼叫父函式時,會產生一個新的執行環境(context),這個環境會保留所有區域變數(local variables),這些區域變數可以透過和全域變數連結、或是從父函式的閉包,在全域環境中被取用。例如以下函式:
x
被綁定在外部函式foo()
中,當執行內部函式bar()
時,bar()
可以取用x
,因為bar()
是在foo()
的作用域中建立的,父函式foo()
執行完後,變數x
被儲存於閉包中,根據JS的Garbage Collection機制,執行bar()
時找到其中有參照變數a
,因此a
不會被清除掉。另一方面,bar()
可以取用其父函式及全域的變數,但是如果bar()
中宣告其它函式、或foo()
中其他函式的作用域(也就是平行於bar()
函式)的變數,則bar()
不可取用這些函式的變數。
Q: 函式中的函式一定代表閉包的存在?
A: 不一定。如果內部的函式(子函式)並沒有參照該函式作用域外(如父函式作用域)的變數,則閉包不存在。例如:
以上例子中,不管傳入任何值到foo()
,回傳的永遠是true,因為內部的函式並沒有參照到foo()
作用域的變數x
,這種狀況下閉包不存在。
巢狀函式根據定義函式的位置,保留父函式的作用域;亦即,內層函式的區塊也可以取用外層函式的變數,例如前述範例中,function nested(b) {return a * b}
,nested()
函式可以取用父函式、也就是curried_multiply()
函式的參數a
,當我們執行const one = curried_multiply(5)
時,one()
保留了curried_multiply()
的作用域,因此可以取用該作用域的變數5
;也可以理解成在此巢狀函式中,第一個傳入的參數a
,會成為閉包中的變數被記憶/儲存,並傳入巢狀函式鏈中的下一個函式執行。
使用ES6的箭頭函式語法,重新撰寫前述範例中的curried_multiply()
函式:
let curried_multiply = a => b => a * b
拆解一下上面這行程式碼:
a => ...
賦值給變數curried_multiply
curried_multiply
是一個接受a
作為參數傳入的函式 )curried_multiply
,傳入參數a
,回傳另一個箭頭函式b => a * b
(i.e. curried_multiply
函式接受a
作為參數傳入後,回傳的函式接受b
作為參數傳入 )b => a * b
,回傳相乘的結果(a * b
)試著將參數傳入這個函式:
以上code做了什麼?
curry
作為外層函式,接受fn
函式作為參數傳入,並回傳另一個函式curried
curried
接受另一參數args
傳入,並用其餘參數(rest parameters)將參數集合成陣列,並且比較fn
和args
的長度if
判斷式中的邏輯: 若fn
和args
的長度(也就是參數的數量)不同,則呼叫bind()
方法,建立一個新的函式並傳入參數...args
;若fn
和args
的長度(也就是預期傳入的參數數量)相同,則回傳傳入args
參數的fn
bind()
是函式的一種方法(method),它的基本語法如下:fun.bind(thisArg[, arg1[, arg2[, ...]]])
this
要指向的物件,第二個與其後的參數則是要傳入該函式的參數。bind()
會建立一個新的函式,必須調用該函式才會執行。
實際應用這個函式:
讓我們更進一步的觀察這個函式做了什麼,在curry
函式中加入一些code:
再執行一次剛剛的curriedTotal(10)(20)(30)
函式,在console上印出:
印出的順序依序是args
、fn.length
、args.length
,因此console印出的東西可以分成三組:
curry
函式的參數是totalNum
函式,這個函式有三個參數,因此fn.length
是3[10]
,也就是args
,此時的args.length
是1,和fn.length
不同,因此執行curried.bind(null, ...args)
,curried
的參數數量變成1[10, 20]
,也就是args
,此時的args.length
是2,和fn.length
不同,因此執行curried.bind(null, ...args)
,curried
的參數數量變成2args
是 [10, 20, 30]
,args.length
是3,和fn.length
相同,因此執行fn(...args)
,也就是把[10, 20, 30]
使用展開運算子(spread operator)傳入totalNum
函式,加總後得到結果60。假如不使用currying的函式,一次傳入三個參數curriedTotal(10, 20, 30)
,則會在console上印出:
因為傳入的參數和totalNum
參數數量相同,因此直接執行fn(...args)
。
以上範例的curry
函式也可以用apply()
方法執行,結果是一樣的:
與bind()
方法的不同之處在於,apply()
方法接受的第二個參數是一個陣列,因此無須再展開。
apply()
方法的基本語法:fun.apply(thisArg, [argsArray])
this
指向的對象,第二個參數則是參數陣列或是array-like 物件;和bind()
方法不一樣,apply()
是直接執行該函式,而非建立新函式(或是說拷貝原函式物件)。
apply()
、bind()
、call()
方法,可以閱讀以下文章:
Currying可以使函式具有單一用途,使程式碼更加模組化,從而更易於測試、debug、維護和閱讀。 以下直接用範例說明:
假如我們有以下的資料,想要進行篩選與排序:
針對candidate
資料根據地點進行篩選並排序,因此寫了以下的函式:
假設今天需要另一個兩個篩選條件的函式來處理這筆資料,於是我們又寫了另一個函式:
以上兩個函式可以觀察到:
試著用Currying改寫以上函式,讓code變得更模組化、容易閱讀、可以重複使用:
code模組化後,可以重用於其他功能,例如可以將上述技能是JavaScript的人選排序:
上面的程式碼中,filterCandidatesByJavaScript
作為sortArrayByValue
的sortArray
參數傳入,並回傳sortKey => ...
這個函式,接著再傳入"dateApplied"
作為sortKey
參數執行sortArray
.sort()
函式,最後回傳排序結果。
所謂的application,是指將函式應用(apply)在其參數(argument)上,以產出一個回傳值的過程;所以,partial application就是將該函式應用於其部分參數的過程,而該函式回傳留作它用。換句話說,partial application就是一個函式接受多個參數、並回傳一個含有較原先參數數量少的函式的過程。和currying一樣,都會應用到閉包的概念,差別在於currying一次只傳入一個參數。 partial application將一或多個參數值固定在回傳的函式中,該回傳函式接受剩餘的參數,以便能完整執行該函式。聽起來好像很奇怪,不過這樣的做法可以減少參數的數量。請看以下範例:
假設今天要做一個商品打折的活動,我們先設定了一個通用的promotionDetails
函式,需要傳入三個參數product
, discount
, startDate
,接著當我們需要分別對各種商品進行處理,又寫了幾個不同的函式,只是…怎麼每個看起來都好像,好多重複的code!試著改寫一下:
看起來是不是比較簡潔?把共通的部分抽取出來,也就是折扣20%,另外寫成函式twentyOffPromotion
,並且把它的參數discount
固定為20這個值,便可以把這個函式應用在其他的函式中,fruitPromotion
和dairyPromotion
都只需要接收參數startDate
即可。這就是partial application。
高階函式意指一個函式可以接收另一函式作為參數、或者回傳一個函式。
上述的範例如果應用高階函式改寫:
partial
函式做了什麼?
fn
, argsToApply
:fn
是要被partially applied的函式,argsToApply
則是收集傳入的參數,利用其餘參數轉換為陣列partial
函式會回傳另一個函式,這個函式接收除了 argsToApply
以外的參數,這裡命名為 restArgsToApply
argsToApply
及 restArgsToApply
;這裡跟currying一樣,應用到閉包的概念,執行外層partial
函式的參數argsToApply
和fn
儲存於閉包中,內部的函式因此可以取得外部函式(父函式)的參數。所以最內層回傳的函式,其實是執行最外層傳入的參數fn
,也就是我們要進行partially applied的函式。