Try   HackMD

JavaScript的柯里化 (Currying in JavaScript)

tags: JavaScript Interview Preparation

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
本站筆記已同步更新到我的個人網站囉! 歡迎參觀與閱讀,體驗不同的視覺感受!

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
(Photo by Kalyani Akella on Unsplash)

此Curry非彼Curry,不過Naan沾咖哩真的好好吃啊

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
前言

柯里化(Currying)是functional programming的一種技術 ,透過currying可以編寫模組化、易於測試和高度可重複使用的程式碼。
Functional programming(FP)是一種宣告式規範( declarative paradigm),強調不變性(immutability)和純函式(Pure Functions)—代表該函式對於任何給定的輸入(input),永遠會回傳相同的輸出(output)。這些特性可以使程式碼更易讀並且更容易維護,而Currying只是其中的一種技術。

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
柯里化(Currying)

原理

Currying可以將具有多個參數的函式轉換為一系列巢狀(nesting)函式。也就是說,函式並非一次接受所有參數,而是接受第一個參數並返回一個新函式,此新函式接受第二個參數並返回另一個新函式,此新函式再接受第三個參數,依此類推,直到所有參數都被執行完畢。

範例

舉例來說,以下函式尚未進行currying:

function multiply(a,b) { return a * b; }

如果只傳入一個參數a,此函式仍會執行,而參數b則會以undefined去執行,也就是說如果執行multiply(5),實際上執行的是5 * undefined,回傳NaN
現在試著將函式currying,轉換成一系列巢狀函式:

function curried_multiply(a) { return function nested(b) { return a * b; } }

調用curried_multiply()函式:

  1. 調用curried_multiply()函式,這個函式接受一個參數傳入,並且回傳另一個函式nested()。也就是調用curried_multiply(a),回傳的結果是nested(b)
  2. 調用nested()函式,這個函式使用調用curried_multiply()以及nested()函式得到的參數ab,並執行與回傳a * b的結果。調用nested(b),回傳的結果是a * b

如果進一步拆解這個函式:

const one = curried_multiply(5) console.log(one) // output: [Function: nested]

回傳的值是nested()函式,接著呼叫one()函式,實際上是執行nested()函式:

one(10) // output: 50

回傳的是 a * b,也就是 5 * 10的值。

閉包(closure)與柯里化(currying)

呼叫curried_multiply()函式時調用的參數可用於巢狀函式,這是閉包(closure)的特性。函式中回傳函式,通常就是閉包。 當呼叫父函式時,會產生一個新的執行環境(context),這個環境會保留所有區域變數(local variables),這些區域變數可以透過和全域變數連結、或是從父函式的閉包,在全域環境中被取用。例如以下函式:

function foo(x) { function bar(y) { console.log(x + y); } bar(2); } foo(2) // output: 4

x被綁定在外部函式foo()中,當執行內部函式bar()時,bar()可以取用x,因為bar()是在foo()的作用域中建立的,父函式foo()執行完後,變數x被儲存於閉包中,根據JS的Garbage Collection機制,執行bar()時找到其中有參照變數a,因此a不會被清除掉。另一方面,bar()可以取用其父函式及全域的變數,但是如果bar()中宣告其它函式、或foo()中其他函式的作用域(也就是平行於bar()函式)的變數,則bar()不可取用這些函式的變數。

Q: 函式中的函式一定代表閉包的存在?
A: 不一定。如果內部的函式(子函式)並沒有參照該函式作用域外(如父函式作用域)的變數,則閉包不存在。例如:

function foo(x) { return function () { return true } } foo(5)() //output: true

以上例子中,不管傳入任何值到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

拆解一下上面這行程式碼:

  1. 把最外層的箭頭函式a => ...賦值給變數curried_multiply
    (i.e. curried_multiply是一個接受a作為參數傳入的函式 )
  2. 呼叫curried_multiply,傳入參數a,回傳另一個箭頭函式b => a * b(i.e. curried_multiply函式接受a作為參數傳入後,回傳的函式接受b作為參數傳入 )
  3. 調用箭頭函式b => a * b,回傳相乘的結果(a * b)

試著將參數傳入這個函式:

let newNum = curried_multiply(2)(8) console.log(newNum) // output: 16

進階Currying範例

const curry =(fn) =>{ return curried = (...args) => { if (fn.length !== args.length){ return curried.bind(null, ...args) } return fn(...args) } }

以上code做了什麼?

  1. curry作為外層函式,接受fn函式作為參數傳入,並回傳另一個函式curried
  2. curried接受另一參數args傳入,並用其餘參數(rest parameters)將參數集合成陣列,並且比較fnargs的長度

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
函式的長度(function length)是function的一種屬性(property),表示該 function 預期被傳入的參數數量,這個數量並不包含其餘參數(rest parameter)且只包含第一個預設參數(Default Parameters)前的參數。
Ref: MDN doc

  1. if判斷式中的邏輯: 若fnargs的長度(也就是參數的數量)不同,則呼叫bind()方法,建立一個新的函式並傳入參數...args;若fnargs的長度(也就是預期傳入的參數數量)相同,則回傳傳入args參數的fn

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
bind()是函式的一種方法(method),它的基本語法如下:
fun.bind(thisArg[, arg1[, arg2[, ...]]])
第一個參數是this要指向的物件,第二個與其後的參數則是要傳入該函式的參數。bind()會建立一個新的函式,必須調用該函式才會執行。

實際應用這個函式:

const totalNum=(x,y,z) => { return x+y+z } const curriedTotal = curry(totalNum) console.log(curriedTotal(10)(20)(30)) // output: 60

讓我們更進一步的觀察這個函式做了什麼,在curry函式中加入一些code:

const curry =(fn) =>{ return curried = (...args) => { // 把args和fn相關資訊印出來 console.log(args) console.log(fn.length) console.log(args.length) if (fn.length !== args.length){ return curried.bind(null, ...args) } return fn(...args) } }

再執行一次剛剛的curriedTotal(10)(20)(30)函式,在console上印出:

// first round [ 10 ] 3 1 // second round [ 10, 20 ] 3 2 // third round [ 10, 20, 30 ] 3 3 // sum output 60

印出的順序依序是argsfn.lengthargs.length,因此console印出的東西可以分成三組:

  1. 傳入curry函式的參數是totalNum函式,這個函式有三個參數,因此fn.length是3
  2. 第一次傳入一個參數,使用其餘參數轉換成陣列 [10],也就是
    args,此時的args.length是1,和fn.length不同,因此執行curried.bind(null, ...args)curried的參數數量變成1
  3. 第二次也是傳入一個參數20,和剛剛的參數10使用其餘參數轉換成陣列 [10, 20],也就是
    args,此時的args.length是2,和fn.length不同,因此執行curried.bind(null, ...args)curried的參數數量變成2
  4. 第三次傳入參數30,此時的args[10, 20, 30]args.length是3,和fn.length相同,因此執行fn(...args),也就是把[10, 20, 30]使用展開運算子(spread operator)傳入totalNum函式,加總後得到結果60。

假如不使用currying的函式,一次傳入三個參數curriedTotal(10, 20, 30),則會在console上印出:

[ 10, 20, 30 ] 3 3 60

因為傳入的參數和totalNum參數數量相同,因此直接執行fn(...args)

以上範例的curry函式也可以用apply()方法執行,結果是一樣的:

function curry(fn) { return function curried(...args) { if (args.length >= fn.length) { return fn.apply(null, args); } else { return function(...args2) { return curried.apply(null, args.concat(args2)); } } } }

bind()方法的不同之處在於,apply()方法接受的第二個參數是一個陣列,因此無須再展開。

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
apply()方法的基本語法:
fun.apply(thisArg, [argsArray])
第一個參數一樣是this指向的對象,第二個參數則是參數陣列或是array-like 物件;和bind()方法不一樣,apply()是直接執行該函式,而非建立新函式(或是說拷貝原函式物件)。

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
更多關於apply()bind()call()方法,可以閱讀以下文章:

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
Currying的優點

Currying可以使函式具有單一用途,使程式碼更加模組化,從而更易於測試、debug、維護和閱讀。 以下直接用範例說明:

假如我們有以下的資料,想要進行篩選與排序:

const candidate = [ { age: 20, location: "CA", skills:"JavaScript", dateApplied: new Date('2021-01-20') }, { age: 26, location: "TX", skills:"Python", dateApplied: new Date('2022-11-09') }, { age: 45, location: "OR", skills:"PHP", dateApplied: new Date('2021-03-06') }, { age: 26, location: "NJ", skills:"JavaScript", dateApplied: new Date('2019-12-30') }, { age: 33, location: "LA", skills:"Python", dateApplied: new Date('2020-05-10') }, { age: 18, location: "CA", skills:"JavaScript", dateApplied: new Date('2018-07-17') }, { age: 31, location: "NJ", skills:"Java", dateApplied: new Date('2022-05-18') } ]

針對candidate資料根據地點進行篩選並排序,因此寫了以下的函式:

const sortByValueFromLocation = (candidateArr, location, sortKey) => { return candidateArr.filter(candidate => { return candidate.location === location }).sort((a,b) => { return a[sortKey] - b[sortKey] }) } console.log(sortByValueFromLocation(candidate, "CA", "age"))

假設今天需要另一個兩個篩選條件的函式來處理這筆資料,於是我們又寫了另一個函式:

const filterByValueFromLocation = (candidateArr, location, filterKey, filterValue) => { return candidateArr.filter(candidate => { return candidate.location === location }).filter(candidateFromCity => candidateFromCity[filterKey] === filterValue) } console.log(filterByValueFromLocation(candidate, "CA", "skills", "JavaScript"));

以上兩個函式可以觀察到:

  1. 這兩個函式都根據地點篩選,並且每次都執行相同的篩選方式: 寫了許多重複的code
  2. 這兩個函式都接受多個參數: 倘若發生錯誤,需要花比較多時間確認是哪個參數造成的錯誤

試著用Currying改寫以上函式,讓code變得更模組化、容易閱讀、可以重複使用:

const setFilter = array => key => value => array.filter(x => x[key] === value); const filterCandidates = setFilter(candidate); const filterCandidatesByLocation = filterCandidates('location'); const filterCandidatesByCA = filterCandidatesByLocation('CA'); const filterCandidatesBySkills = filterCandidates('skills'); const filterCandidatesByJavaScript = filterCandidatesBySkills('JavaScript'); console.log(filterCandidatesByCA) // 回傳地點是CA的人選 console.log(filterCandidatesByJavaScript); // 回傳技能是JavaScript的人選

code模組化後,可以重用於其他功能,例如可以將上述技能是JavaScript的人選排序:

const sortArrayByValue = sortArray => sortKey => { return sortArray.sort(function(a, b){ if(a[sortKey] < b[sortKey]) { return -1; } if(a[sortKey] > b[sortKey]) { return 1; } return 0; }); } const sortJS = sortArrayByValue(filterCandidatesByJavaScript); const sortJSByDate = sortJS("dateApplied"); console.log(sortJSByDate) /* output: [ { age: 18, location: 'CA', skills: 'JavaScript', dateApplied: 2018-07-17T00:00:00.000Z }, { age: 26, location: 'NJ', skills: 'JavaScript', dateApplied: 2019-12-30T00:00:00.000Z }, { age: 20, location: 'CA', skills: 'JavaScript', dateApplied: 2021-01-20T00:00:00.000Z } ] */

上面的程式碼中,filterCandidatesByJavaScript作為sortArrayByValuesortArray參數傳入,並回傳sortKey => ... 這個函式,接著再傳入"dateApplied"作為sortKey參數執行sortArray.sort()函式,最後回傳排序結果。

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
Partial application vs. Currying

什麼是Partial Application

所謂的application,是指將函式應用(apply)在其參數(argument)上,以產出一個回傳值的過程;所以,partial application就是將該函式應用於其部分參數的過程,而該函式回傳留作它用。換句話說,partial application就是一個函式接受多個參數、並回傳一個含有較原先參數數量少的函式的過程和currying一樣,都會應用到閉包的概念,差別在於currying一次只傳入一個參數。 partial application將一或多個參數值固定在回傳的函式中,該回傳函式接受剩餘的參數,以便能完整執行該函式。聽起來好像很奇怪,不過這樣的做法可以減少參數的數量。請看以下範例:

const promotionDetails = (product, discount, startDate) => { return `The ${product} price is ${discount}% off from ${startDate} ` } const fruitPromotion = startDate => { return promotionDetails('fruits', 20, startDate) } const dairyPromotion = startDate => { return promotionDetails('dairy products', 20, startDate) }

假設今天要做一個商品打折的活動,我們先設定了一個通用的promotionDetails函式,需要傳入三個參數product, discount, startDate,接著當我們需要分別對各種商品進行處理,又寫了幾個不同的函式,只是怎麼每個看起來都好像,好多重複的code!試著改寫一下:

const promotionDetails = (discount, product, startDate) => { return console.log(`The ${product} price is ${discount}% off from ${startDate} `) } const twentyOffPromotion = (product, startDate) => { return promotionDetails(20, product, startDate) } const fruitPromotion = startDate => { return twentyOffPromotion('fruits', startDate) } const dairyPromotion = startDate => { return twentyOffPromotion('dairy products', startDate) }

看起來是不是比較簡潔?把共通的部分抽取出來,也就是折扣20%,另外寫成函式twentyOffPromotion,並且把它的參數discount固定為20這個值,便可以把這個函式應用在其他的函式中,fruitPromotiondairyPromotion都只需要接收參數startDate即可。這就是partial application。

高階函式 (High Order Function)

高階函式意指一個函式可以接收另一函式作為參數、或者回傳一個函式。

上述的範例如果應用高階函式改寫:

const partial = (fn, ...argsToApply) => { return (...restArgsToApply) => { return fn(...argsToApply, ...restArgsToApply) } } const promotionDetails = ( discount, product, startDate) => { return console.log(`The ${product} price is ${discount}% off from ${startDate} `) } const twentyOffPromotion = partial(promotionDetails, 20) const fruitPromotion = partial(twentyOffPromotion, 'fruits', '2020/02/20') fruitPromotion() // The fruits price is 20% off from 2020/02/20 const dairyPromotion = partial(twentyOffPromotion, 'dairy products', '2020/01/23') dauryPromotion() // The dairy products price is 20% off from 2020/01/23

partial函式做了什麼?

  1. 傳入兩個參數fn, argsToApply:fn是要被partially applied的函式,argsToApply則是收集傳入的參數,利用其餘參數轉換為陣列
  2. 執行partial函式會回傳另一個函式,這個函式接收除了 argsToApply以外的參數,這裡命名為 restArgsToApply
  3. 執行這個回傳的函式會再回傳另一個函式,這個函式接收兩個參數argsToApplyrestArgsToApply;這裡跟currying一樣,應用到閉包的概念,執行外層partial函式的參數argsToApplyfn儲存於閉包中,內部的函式因此可以取得外部函式(父函式)的參數。所以最內層回傳的函式,其實是執行最外層傳入的參數fn,也就是我們要進行partially applied的函式。

參考資料

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
 本站內容僅為個人學習記錄,如有錯誤歡迎留言告知、交流討論!