純函數(shù)
函數(shù)式編程中的函數(shù),指的就是純函數(shù),這也是整個(gè)函數(shù)式編程的核心
純函數(shù):相同的輸入永遠(yuǎn)會得到相同的輸出,而且沒有任何可觀察的副作用。
純函數(shù)就類似數(shù)學(xué)中的函數(shù)(用來描述輸入和輸出之間的關(guān)系),y = f(x)

- lodash 是一個(gè)純函數(shù)的功能庫,提供了對數(shù)組、數(shù)字、對象、字符串、函數(shù)等操作的一些方法
來感受下啥叫純與不純
數(shù)組的 slice 和 splice 分別是:純函數(shù)和不純的函數(shù)
slice: 返回?cái)?shù)組中的指定部分,不會改變原數(shù)組
splice: 對數(shù)組進(jìn)行操作返回該數(shù)組,會改變原數(shù)組
let numbers = [1, 2, 3, 4, 5]
// 純函數(shù)
numbers.slice(0, 3)// => [1, 2, 3]
numbers.slice(0, 3)// => [1, 2, 3]
numbers.slice(0, 3)// => [1, 2, 3]
// 不純的函數(shù)
numbers.splice(0, 3)// => [1, 2, 3]
numbers.splice(0, 3)// => [4, 5]
numbers.splice(0, 3)// => []
可以看到每次都是相同的輸入,slice每次都是相同的輸出,所以他是純函數(shù),由于splice會改變原數(shù)組,雖然相同輸入,每次輸出都變了,所以不純
- 函數(shù)式編程不會保留計(jì)算中間的結(jié)果,所以變量是不可變的(無狀態(tài)的)
- 我們可以把一個(gè)函數(shù)的執(zhí)行結(jié)果交給另一個(gè)函數(shù)去處理
就比如這個(gè)slice這個(gè)純函數(shù),我們在調(diào)用他的時(shí)候會傳遞參數(shù),然后會獲取結(jié)果,而函數(shù)內(nèi)部的結(jié)果我們是無法獲取到的,也就是他不會保留內(nèi)部的計(jì)算中間的結(jié)果,所以我們認(rèn)為函數(shù)式編程的變量是不可變的
所以在基于函數(shù)式編程的過程中,我么會經(jīng)常需要一些細(xì)粒度的純函數(shù),要是自己去寫細(xì)粒度的純函數(shù),要寫非常多,并不方便,所以我們有好多函數(shù)式編程的庫,比如說Lodash,有了這些細(xì)粒度的函數(shù),我們可以把一個(gè)函數(shù)的執(zhí)行結(jié)果交給另一個(gè)函數(shù)去處理,我們就能組合出功能更強(qiáng)大函數(shù)
Lodash
去官網(wǎng)看看吧,都是些好用的方法,https://www.html.cn/doc/lodash/
純函數(shù)的好處
- 可緩存
因?yàn)榧兒瘮?shù)對相同的輸入始終有相同的結(jié)果,所以可以把純函數(shù)的結(jié)果緩存起來
假設(shè)有一個(gè)超復(fù)雜的計(jì)算的函數(shù),比如要計(jì)算地球的體積,他的入?yún)⑹堑厍虬霃?,每?jì)算一次要耗時(shí)一秒,那么我們就可以把計(jì)算結(jié)果儲存起來,因?yàn)閷ο嗤妮斎胧冀K有相同的結(jié)果,其中l(wèi)odash里有這么個(gè)記憶函數(shù)memoize
const _ = require('lodash')
function getVolume (r) {
console.log("半徑是" + r)
return 4 / 3 * Math.PI * r * r *r
}
let getVolumeWithMemory = _.memoize(getVolume)
console.log(getVolumeWithMemory(10))
console.log(getVolumeWithMemory(10))
console.log(getVolumeWithMemory(10))
// 輸出:
// 半徑是10
// 4188.79
// 4188.79
// 4188.79
被memoize包裹后形成的getVolumeWithMemory,入?yún)⒑蚲etVolume是一樣的,可以看到半徑是10只執(zhí)行了一遍,10=>4188.79這個(gè)結(jié)果就被存起來了,下次再遇到,就直接拿出結(jié)果了,不需要再執(zhí)行了。
我們自己模擬一個(gè)memoize
function memoize (f) {
let cache = {}
// 這個(gè)cache就是用來緩存結(jié)果的,用f的入?yún)?dāng)key,用出參當(dāng)value,比如上面的getVolumeWithMemory執(zhí)行完后就形成了{(lán)10 : 4188.79}
return function () {
let key = JSON.stringify(arguments) // arguments可能是個(gè)數(shù)組或其他形式,所以轉(zhuǎn)成字符串
cache[key] = cache[key] || f.apply(f, arguments) // cache[key]存在就取cache[key],不存在就執(zhí)行f,因?yàn)閍rguments是數(shù)組所以用apply方法
return cache[key]
}
}
可測試
純函數(shù)讓測試更方便并行處理
在多線程環(huán)境下并行操作共享的內(nèi)存數(shù)據(jù)很可能會出現(xiàn)意外情況
純函數(shù)不需要訪問共享的內(nèi)存數(shù)據(jù),所以在并行環(huán)境下可以任意運(yùn)行純函數(shù) (es6 新增的Web Worker,讓js有了多線程能力)
副作用
純函數(shù):對于相同的輸入永遠(yuǎn)會得到相同的輸出,而且沒有任何可觀察的副作用
// 不純的
let mini = 18
function checkAge (age) {
return age >= mini
}
//你看那個(gè)不純的,你敢保證每次輸入20都返回true么,因?yàn)樗蕾嚵艘粋€(gè)外部變量,無法知曉此變量何時(shí)會被篡改
// 純的(有硬編碼,后續(xù)可以通過柯里化解決)
function checkAge (age) {
let mini = 18
return age >= mini
}
副作用讓一個(gè)函數(shù)變的不純(如上例),純函數(shù)的根據(jù)相同的輸入返回相同的輸出,如果函數(shù)依賴于外部的狀態(tài)就無法保證輸出相同,就會帶來副作用。
副作用的來源除了一些全局變量,還有配置文件,數(shù)據(jù)庫,獲取用戶的輸入等等
所有的外部交互都有可能帶來副作用,副作用也使得方法通用性下降不適合擴(kuò)展和可重用性,同時(shí)副作用會給程序中帶來安全隱患給程序帶來不確定性,比如用戶的賬號密碼是要存在數(shù)據(jù)庫而非函數(shù)里的,所以副作用不可能完全禁止,但要盡可能控制它們在可控范圍內(nèi)發(fā)生。
柯里化
這里我們先上一個(gè)案例,用柯里化來解決上一個(gè)例子中硬編碼的問題
function checkAge (age) {
let mini = 18
return age >= mini
}
// 既然有硬編碼,那我們通過把min字段傳進(jìn)去,不就解決了么,于是
// 普通純函數(shù)
function checkAge (age, min) {
return age >= min
}
checkAge(20, 18)
checkAge(24, 18)
checkAge(26, 30)
// 假設(shè)經(jīng)常以18,30為基準(zhǔn)值,每次都輸入18,30就過于重復(fù)了
// 想想我們之前在閉包那里是怎么處理的
// 柯里化
function checkAge (min) { //既然經(jīng)常是基準(zhǔn)值不變的,所以就讓基準(zhǔn)值通過閉包儲存起來
return function (age) {
return age >= min
}
}
let checkAge18 = checkAge(18)
let checkAge30 = checkAge(30)
checkAge18(24) // 再判斷數(shù)字的時(shí)候就不用輸入18了,這就是函數(shù)柯里化
checkAge18(20)
// ES6 寫法
let checkAge = min => (age => age >= min)
柯里化:
- 當(dāng)一個(gè)函數(shù)有多個(gè)參數(shù)的時(shí)候先傳遞一部分參數(shù)調(diào)用它(這部分參數(shù)以后永遠(yuǎn)不變)
- 然后返回一個(gè)新的函數(shù)接收剩余的參數(shù),返回結(jié)果
我們看看普通純函數(shù)變成柯里化函數(shù)的過程是不是就是如上定義一樣,還挺套路的
Lodash中的柯里化
既然如此套路,Lodash中也有通用的柯里化方法curry
_.curry(func)
- 功能:創(chuàng)建一個(gè)函數(shù),該函數(shù)接收一個(gè)或多個(gè) func 的參數(shù),如果 func 所需要的參數(shù)都被提供則執(zhí)行 func 并返回執(zhí)行的結(jié)果。否則繼續(xù)返回該函數(shù)并等待接收剩余的參數(shù)。
- 參數(shù):需要柯里化的函數(shù)
- 返回值:柯里化后的函數(shù)
const _ = require('lodash')
// 要柯里化的函數(shù)
function getSum (a, b, c) {
return a + b + c
}
// 柯里化后的函數(shù)
let curried = _.curry(getSum)
// 測試
curried(1, 2, 3)
curried(1)(2)(3)
curried(1, 2)(3) // 輸出結(jié)果都是6
柯里化讓多元函數(shù)轉(zhuǎn)變成了一元函數(shù)(幾個(gè)入?yún)⒕褪菐自瘮?shù),上面的getSum就是三元函數(shù))
柯里化案例
用上述的curry方法來實(shí)現(xiàn)一個(gè)案例:提取一個(gè)字符串中的所有數(shù)字
// 面向過程的方式, 正則match
''.match(/\d+/g) // 數(shù)字
// 如果改成提取數(shù)組中含有數(shù)字的元素,上面的方法就不通用了,所以我們用柯里化來包裝一下
const _ = require('lodash')
let match = _.curry(function (reg, str) {
return str.match(reg)
})
// 讓他具有特定功能
let findStrNum = match(/\d+/g) //尋找數(shù)字
let findStrSpace = match(/\s+/g) //尋找空格
// 試一試
console.log(findStrNum('asd1234')) // true
console.log(findStrSpace('asd1234')) // false
// OK,到這里,關(guān)于字符串的match就都可以實(shí)現(xiàn)了
// 現(xiàn)在我們要繼續(xù)用上面方法,來實(shí)現(xiàn)數(shù)組中的提取含有數(shù)字的項(xiàng)
// 數(shù)組需要循環(huán),我們來一個(gè)柯里化的filter
let filter = function (fn, arr) { // 這里的fn是要做操作的函數(shù)
return arr.filter(fn) // 這個(gè)filter是數(shù)組的filter方法,別搞混
}
let filterCurry = _.curry(filter) // 柯里化
// 讓他具有特定功能, 那么就可以傳進(jìn)去上面定義的findStrNum
let findArrNum = filterCurry(findStrNum)
// 試一試
console.log(findArrNum(['abc123', 'abc']))
最后我們就用函數(shù)式的方式,實(shí)現(xiàn)了這個(gè)功能,可能覺得這樣寫非常麻煩,還不如自己去正則寫來實(shí)現(xiàn),但是你要清楚的是,將來這些函數(shù),我們可以不停的重復(fù)使用。
const _ = require('lodash')
let match = _.curry(function (reg, str) {
return str.match(reg)
})
let findStrNum = match(/\d+/g)
let findStrSpace = match(/\s+/g)
let filterCurry = _.curry(function (fn, arr) {
return arr.filter(fn)
})
let findArrNum = filterCurry(findStrNum)
let findArrSpace = filterCurry(findStrSpace)
看看上面這些東西,定義一次,就可以在你的工程中無數(shù)次的重復(fù)使用,能避免自己造輪子中的小bug
模擬 _.curry() 的實(shí)現(xiàn)
// 先來看一下這玩意當(dāng)時(shí)是如何使用的
const _ = require('lodash')
function getSum (a, b, c) {
return a + b + c
}
let curried = _.curry(getSum)
curried(1, 2, 3) // 它的調(diào)用形式分為傳入全部參數(shù),或部分參數(shù)
curried(1, 2)(3)
// 自己實(shí)現(xiàn)
function curry (func) {
return function curriedFn (...args) {
// 判斷實(shí)參和形參的個(gè)數(shù)
if (args.length < func.length) {
return function () {
return curriedFn(...args.concat(Array.from(arguments))) // 這里面就是把每次的參數(shù)結(jié)合起來,再次調(diào)用curriedFn
}
}
// 實(shí)參和形參個(gè)數(shù)相同,調(diào)用 func,返回結(jié)果
return func(...args)
}
}
柯里化總結(jié)
- 柯里化可以讓我們給一個(gè)函數(shù)傳遞較少的參數(shù)得到一個(gè)已經(jīng)記住了某些固定參數(shù)的新函數(shù)
- 這是一種對函數(shù)參數(shù)的'緩存'
- 讓函數(shù)變的更靈活,讓函數(shù)的粒度更小
- 可以把多元函數(shù)轉(zhuǎn)換成一元函數(shù),可以組合使用函數(shù)產(chǎn)生強(qiáng)大的功能
下篇再整理下函數(shù)的組合,以避免柯里化后洋蔥圈似的代碼
函數(shù)式編程(三)