JS 函數式程式設計: 高階函數之柯裡化(currying)和反柯裡化(uncurrying)
文章目錄
- JS 函數式程式設計: 高階函數之柯裡化(currying)和反柯裡化(uncurrying)
-
- 簡介
- 參考
- 完整示例代碼
- 正文
-
- 柯裡化 Currying
-
- 實作目标
- 基礎實作
- 特殊終止條件
- 函數内部柯裡化
- 柯裡化的應用
-
- 環境相容性
- `Function.prototype.bind`
- 反柯裡化 Uncurrying
-
- `Function.prototype.call` 實作
- `Function.prototype.apply` 實作
- `Reflect.apply` 實作
- 結語
簡介
柯裡化(currying) 和 反柯裡化(uncurrying) 是函數式程式設計中一個非常重要的技巧。部分求值、惰性求值、提前綁定 等特性,能夠在原有函數的基礎之上創造出更多更靈活的用法。本篇就來介紹到底什麼是函數柯裡化。
參考
JavaScript高階函數之currying和uncurrying | https://www.imooc.com/article/details/id/4381 |
JavaScript高階函數之uncurrying和currying | https://blog.csdn.net/xuyankuanrong/article/details/80775504 |
Favoring Curry | https://fr.umio.us/favoring-curry/ |
JavaScript之高階函數 | https://www.jianshu.com/p/f019f980a50d |
function中的callee和caller | https://blog.csdn.net/qq_35087256/article/details/80023131 |
JavaScript中的函數式程式設計 | https://www.jianshu.com/p/a5131f3dfb0f |
前端柯裡化的三種作用 | https://blog.csdn.net/qq_39674542/article/details/82657109 |
完整示例代碼
https://github.com/superfreeeee/Blog-code/tree/main/front_end/javascript/js_currying
正文
柯裡化 Currying
-
從 表現形式 的角度,我們可以這樣描述柯裡化函數:
柯裡化後的函數,每次可以隻接受原函數需要的部分參數,并傳回一個能繼續接受剩餘參數的函數,直到 參數全部傳入 或 滿足終止條件時 才傳回結果。
- 從 應用場景 的角度我們可以說:
- 函數定制/提前綁定:根據柯裡化函數的特性,我們可以提前傳入/判斷環境參數進行綁定,傳回一個定制好的函數,不僅能夠很好的避免表達式的重複,也能更清晰的表示函數邏輯
- 延遲執行:從原來向函數傳入所有需要的參數,變成依次傳入部分參數的形式,能夠将各個參數計算的時機分開,同時也能夠延遲最終結果的執行。
實作目标
首先我們先給出一個最淺白的例子來表達我們想要完成的柯裡化的目标:
// simple.js
const f1 = function (a, b, c) {
return [a, b, c]
}
const f2 = (a) => (b) => (c) => [a, b, c]
f1(1, 2, 3) // [1, 2, 3]
f2(1)(2)(3) // [1, 2, 3]
上面的例子說明了柯裡化最基本的樣貌,我們希望透過某個柯裡化函數(currying),來完成
f2 = currying(f1)
的轉化。
基礎實作
第一種給出一個最經典也是通用版本的柯裡化實作方案:
// currying.js
function currying(fn) {
const len = fn.length
const params = []
const inner = (...args) => {
args.forEach((arg) => params.push(arg))
if (params.length >= len) {
const res = fn(...params.slice(0, len))
params.length = 0
return res
} else {
return inner
}
}
return inner
}
代碼解釋:我們先記錄原函數需要的參數數量,然後建立一個内部遞歸函數
inner
,該函數會不斷收集新的參數,等參數足夠後才真正調用原方法
fn(...)
;同時,為了使該柯裡化函數傳回的函數能重複使用,每次調用原方法之後需要清除已經收集的參數清單
params.length = 0
,下面給出測試用例(使用
jest
測試架構)
// currying.test.js
test('test currying', () => {
function abc(a, b, c) {
return [a, b, c]
}
const curried = currying(abc)
expect(curried(1)(2)(3)).toEqual([1, 2, 3])
expect(curried(1, 2)(3)).toEqual([1, 2, 3])
expect(curried(1, 2, 3)).toEqual([1, 2, 3])
})
我們可以看到
abc
函數從原來需要三個參數,變成可以接受多次調用直到累計滿足三個參數才傳回結果。
特殊終止條件
前一種經典的實作的終止條件(調用時機)是根據原函數參數數量來決定,有的時候被柯裡化的函數可能會有不同種的終止條件,如下面這個不定參數的加總函數:
function adder(...nums) {
let res = 0
nums.forEach((num) => (res += num))
return res
}
要想對這種函數進行柯裡化,使用前面給出的那種方案是不行的,是以接下來我們給出一個可以 接受無限參數 的柯裡化方案,其終止條件為無參數傳入的調用:
// currying.js
function curryingInfinite(fn) {
const params = []
const inner = (...args) => {
if (args.length === 0) {
const res = fn(...params)
params.length = 0
return res
} else {
args.forEach((arg) => params.push(arg))
return inner
}
}
return inner
}
代碼解釋:這次内部的遞歸函數的檢查條件變為傳入參數的長度,無參數傳入時代表傳回結果,下面看看測試用例
// currying.test.js
test('test curryingInfinite', () => {
function adder(...nums) {
let res = 0
nums.forEach((num) => (res += num))
return res
}
const curried = curryingInfinite(adder)
expect(curried(1, 2, 3)(4)(5)(6, 7)()).toBe(28)
expect(curried(1, 2, 3, 4, 5, 6, 7)()).toBe(28)
})
我們可以看到,這次的柯裡化實作版本從傳入指定數量的參數,變為不斷接受參數直到無參數調用(調用沒有傳入參數)時才傳回結果
函數内部柯裡化
從上面兩個例子我們可以發現,一個通用的柯裡化函數有時候并不是那麼好用,可能需要根據需要柯裡化的函數和使用的場景去做調整。是以實際上柯裡化更多的隻是一個 思想,我們可以将柯裡化的行為内置到函數裡面,或是說從一開始以柯裡化的角度來定義函數。例如我們現在來簡化第二個例子中的
curryingInfinite + adder
的組合:
// currying.js
function curriedAdder() {
let sum = 0
const inner = (...nums) => {
if (nums.length === 0) {
return sum
} else {
nums.forEach((num) => (sum += num))
return inner
}
}
return inner
}
一樣的測試用例
// currying.test.js
test('test curriedAdder', () => {
expect(curriedAdder()(1, 2, 3)(4)(5)(6, 7)()).toBe(28)
expect(curriedAdder()(1, 2, 3, 4, 5, 6, 7)()).toBe(28)
})
柯裡化的應用
環境相容性
有些時候我們的代碼需要保證浏覽器甚至運作環境的相容性,我們需要對一些全局函數進行檢查如下:
var addEvent = function(ele, type, fn) {
if (window.addEventListener) {
return ele.addEventListener(type,fn,false);
} else if (window.attachEvent) {
return ele.attachEvent(type, fn);
}
}
然而這樣寫有一個嚴重的缺陷就是,當我們每次調用這個相容性的
addEvent
方法時,都必須經過一次
if-else
的判斷。這時我們就可以使用柯裡化的思想,定制 好一個環境相關的全局函數,往後直接調用已經綁定好的函數即可:
var addEvent = function(ele, type, fn) {
if (window.addEventListener) {
addEvent = function(ele, type, fn) {
ele.addEventListener(type,fn,false);
}
} else if (window.attachEvent) {
addEvent = function(ele, type, fn) {
ele.attachEvent(type,fn);
}
}
//執行
addEvent(ele, type, fn);
}
改寫後的函數會在第一次調用的時候直接綁定與環境比對的方法,往後的調用就能直接使用正确的方法而不再需要額外的條件判斷
Function.prototype.bind
Function.prototype.bind
Function.prototype.bind
方法本身就是一種柯裡化思想的展現。我們知道在 js 中一個函數會根據調用上下文的不同改變
this
關鍵字的指向。這時我們就能夠使用
bind
方法綁定一個上下文,使得不管在哪裡直接調用方法都能有一樣的結果:
function f() {
console.log(this)
}
f() // window / global
const obj = { name: 'superfree' }
const bindingF = f.bind(obj)
bindingF() // { name: 'superfree' }
反柯裡化 Uncurrying
第二個比較少聽到的是一個叫 反柯裡化(uncurrying) 的思想。這裡容易産生的一個誤解是,反柯裡化并不是作為柯裡化函數的反函數而存在,僅僅隻是名字上存在關聯。
反柯裡化的作用類似于 借用方法。前面我們提到柯裡化可以提前綁定函數調用的上下文(也就是
this
關鍵字的指向),而反柯裡化的作用之一就是解藕出一個綁定好的上下文的方法,聽起來好像就是
Function.prototype.call
方法是不是!
下面我們給出三種反柯裡化的實作方式,分别使用了
Function.prototype.call
、
Function.prototype.apply
、
Reflect.apply
Function.prototype.call
實作
Function.prototype.call
// uncurrying.js
function uncurryingByCall(fn) {
return function (ctx, ...args) {
return fn.call(ctx, ...args)
}
}
- 測試
test('test uncurryingByCall', () => {
const slice = uncurryingByCall(Array.prototype.slice)
expect(slice([1, 2, 3, 4, 5], 1, 3)).toEqual([2, 3])
})
Function.prototype.apply
實作
Function.prototype.apply
// uncurrying.js
function uncurryingByApply(fn) {
return function (ctx, ...args) {
return fn.apply(ctx, args)
}
}
- 測試
test('test uncurryingByApply', () => {
const slice = uncurryingByApply(Array.prototype.slice)
expect(slice([1, 2, 3, 4, 5], 1, 3)).toEqual([2, 3])
})
Reflect.apply
實作
Reflect.apply
// uncurrying.js
function uncurryingByReflect(fn) {
return (ctx, ...args) => Reflect.apply(fn, ctx, args)
}
- 測試
test('test uncurryingByReflect', () => {
const slice = uncurryingByReflect(Array.prototype.slice)
expect(slice([1, 2, 3, 4, 5], 1, 3)).toEqual([2, 3])
})
結語
柯裡化和反柯裡化都是圍繞着參數/上下文綁定在進行的,在實際的開發場景之中其實是非常有用的一個小技巧。由于函數在 js 語言之中屬于一等公民,當我們發現總是在使用相同的參數重複調用同樣的方法的時候,我們就可以考慮使用柯裡化的思想來定制化(提前綁定參數/上下文)一個新的函數,不僅能夠優化調用性能,代碼的可讀性也是 upup。