關於函數語言程式設計應該知道的基礎
技術標籤:javascript函數語言程式設計
內容概要
- 為什麼學習函數語言程式設計,以及什麼是函式程式設計
- 函數語言程式設計的特性(純函式,柯里化,函式組合)
- 函數語言程式設計的應用場景
- 函數語言程式設計庫 Lodash
為什麼學些函數語言程式設計
隨著React 的流行備受關注,Vue3.0也開始擁抱函式式,函式式可以拋棄this,打包過程中更好的利用tree shaking 過濾無用程式碼,方便測試,方便並行處理,有很多庫可以幫助我們進行函式開發 Lodash,underscorce,ramda
什麼是函式程式設計
函數語言程式設計是程式設計正規化之一,常說的還有面向物件和麵向過程
面向物件
函數語言程式設計: 把現實世界中事物和事物之間的聯絡抽象成程式世界,是對運算過程的抽象,像純函式(相同的輸入得到相同的輸出) eg. y=sin(x-0)
函數語言程式設計–前置知識
- 函式是一等公民
- 高階函式
- 閉包
函式是一等公民
- 函式可以儲存在變數中
- 函式可以作為引數
- 函式可以作為返回值
在JavaScript中函式式一個普通的物件 (可以通過 new Function),我們可以把函式儲存在變數/陣列中,它還可以作為另一個函式的引數和返回值,甚至我們可以在程式執行的時候通過new Function(‘alert(1)’)來構造一個新的函式
把函式賦值給變數
let fn = function(){
console.log(`hello`)
}
fn()
高階函式
- 高階函式(Higher-order function)
- 可以把函式作為引數傳遞給另一個函式
- 可以把函式作為另一個函式的返回結果
函式作為引數傳遞給另一個函式
// 模擬forEach
function forEach(arr,fn){
for( let i = 0; i < arr.length; i++ ){
fn(arr[i])
}
}
//模擬filter
function filter(arr,fn){
const res = []
for ( let i = 0; i < arr.length; i++ ){
if(fn(arr[i])){
res.push(arr[i])
}
}
return res
}
函式作為返回值
function makeFn(){
let msg = `hello function`
return function(){
console.log(msg)
}
}
// 模擬once 使用場景:支付場景
function once(fn){
const done = false
return function(){
if(!done){
done = true
return fn.apply(this, arguments) // 使用者呼叫的時候可能會傳遞引數,所以把呼叫當前函式的引數傳遞給fn
}
}
}
let pay = once(function(money){
console.log(`支付了 ${money} RMB`)
})
使用高階函式的意義
- 抽象可以幫我們遮蔽細節,只需要關注於我們的目標
- 高階函式是用來抽象通用的問題
常見的高階函式
// 模擬map
const map = (arr,fn)=>{
const results = []
for(const value of arr ){
results.push(fn(value))
}
return results
}
// 模擬every
const every = (array, fn) => {
let result = true
for(let value of array){
result = fn(value)
if(!result){
break
}
}
return result
}
// 模擬some
const some = (array, fn) => {
let result = false
for(let value of array){
result = fn(value)
if(!result){
break
}
}
return result
}
閉包
閉包的概念
閉包(Closure): 函式和其周圍的狀態(詞法環境)的引用捆綁在一起行程閉包
- 可以在另一個作用於中呼叫一個函式的內部函式並訪問到改函式的作用域中的成員
function makeFn (){
let msg = `Hello`
return function(){
console.log(msg)
}
}
const fn = makeFn() // 因為外部對內部的函式有引用,所以內部的msg變數不能被釋放掉
fn()
- 閉包的本質:函式在執行的時候會被放到一個執行棧上,當函式執行完畢後,會被從執行棧中移出,**但是堆上的作用域成員因為被外部引用不能釋放**,因此內部函式依然可以訪問外部函式成員
閉包 的案例
// 求平方
function makePower (power){
return function (number){
Math.pow(number,power)
}
}
const power2 = makePower(3) // 求3的平方
// 求員工的工資
function makeSalary (base){
return function (performance){
return base + performance
}
}
純函式
純函式的概念
純函式 相同的輸入永遠會得到相同的輸出,而且沒有任何可觀察的副作用
- 純函式就類似於數學中的函式,用來描述輸入和輸出之間的關係,y=f(x)
- lodash 是一個純函式的功能庫,提供了對陣列、數字、物件、字串、函式等操作的一些方法
- 陣列中的slice 和 splice 分別是:純函式和不純函式
- slice 返回陣列中的指定部分,不會改變原陣列
- splice 對陣列進行操作返回陣列,會改變原陣列
let array = [1,2,3,4,5]
// 純函式
console.log(array.slice(0,3)) // [1,2,3]
console.log(array.slice(0,3)) // [1,2,3]
// 不純函式
console.log(array.splice(0,3)) // [1,2,3]
console.log(array.splice(0,3)) // [4,5]
- 函數語言程式設計不會保留計算中間的結果,所以變數是不可變的(無狀態的)
- 我可以把一個函式的執行結果交給另一個函式去處理
Lodash中提供的純函式
const array = ['jack','lucy','mack','nike']
console.log(_.first(array)) // jack
console.log(_.last(array)) // nike
console.log(_.toUpper(_.first(array))) JACK
純函式的好處
- 可快取
因為相同的輸入始終會有相同的結果,所以可以把純函式的結果快取起來
使用快取的原因:如果有個函式呼叫起來特別耗時,並且需要多次呼叫,可以在第一次呼叫的時候把結果快取起來,第二次呼叫直接返回快取的結果
// lodash中的記憶函式
const _ = require('lodash')
function getArea(r){
console.log(r)
return Math.PI *r *r
}
let getAreaWithMemory = _.memoize(getArea)
console.log(getAreaWithMemory(4))
console.log(getAreaWithMemory(4))
console.log(getAreaWithMemory(4))
// 4
// 圓的面積
// 圓的面積
// 圓的面積
模擬memoize
function memoize(f){
const cache = {}
return function(){
let key = JSON.stringfy(arguments)
cache[key] = cache[key] || f.apply(f,arguments)
return cache[key]
}
}
- 可測試
因為純函式始終有輸入輸出,讓測試更方便 - 並行處理
- 在多執行緒環境下並行操作共享的記憶體資料可能會發生意外情況
- 純函式不需要訪問共享的記憶體資料,所以在並行環境下可以任意執行純函式
純函式的副作用
- 純函式:對於相同的輸入永遠會得到相同的輸出,而且沒有任何可觀察的副作用
// 不純的
let mini = 18
function checkAge(age){
return age >= mini
}
// 純的(有硬編碼,後續通過柯里化解決)
function checkAge (age){
let mini = 18
return age >= mini
}
副作用讓一個函式變得不純(如上例),純函式是根據相同的輸入返回相同的輸出,如果函式依賴於外部的狀態就無法保成輸出相同,就會帶來副作用
副作用的來源:
- 配置檔案
- 資料庫
- 獲取使用者的輸入
- …
所有的外部互動都有可能帶來副作用,副作用也使得方法通用性下降不適合拓展和可重用性,同時副作用會給程式帶來安全應還給程式帶來不確定性,但是副作用不可能完全禁止,儘可能控制他們在可控範圍內發生
柯里化
柯里化概念
- 當一個函式有多個引數的時候先傳遞一部分引數呼叫它(這部分引數以後永遠不變)
- 然後返回一個新的函式接受剩餘的引數,返回結果
使用柯里化解決上一個案例中硬編碼的問題
// 存在硬編碼的函式
function checkAge(age){
let min = 18
return age >= mini
}
// 普通的純函式
function checkAge( min, age){
return age >= min
}
// 函式的柯里化
function checkAge(min){
return function (age){
return age >= mini
}
}
// ES寫法
let checkAge = min => (age=> age >= min)
let checkAge18 = checkAge(18)
checkAge18(20) // true
Lodash中的柯里化函式
_.curry(func)
- 功能:建立一個函式,該函式接收一個或多個func的引數,如果func所需要的引數都被提供則執行func並返回執行結果,否則繼續執行返回該函式並等待接收剩餘的引數
- 引數: 需要柯里化的函式
- 返回值: 柯里化後的函式
// lodash 中 curry 的基本使用
// 柯里化可以把任意多元的函式轉化為一元的函式
const _ = require('lodash')
function getSum(a,b,c){
return a + b + c
}
const curried = _.curry(getSum)
console.log(curried(1,2,3)) // 6 因為傳遞了func所需的所有引數,func會被執行並返回執行結果
console.log(curried(1)(2,3)) //當傳遞了一個函式後,會返回一個新的函式並等待接收剩餘引數
console.log(curried(1,2)(3))
柯里化的案例
// 匹配字串中的所有空白字元
// 之後還需要提取字串中的數字
// 面向過程的方式
' '.match(/\s+/g)
' '.match(/\d+/g)
// 純函式
function match (reg,str) {
return str.match(reg)
}
// 柯里化處理
const _ = require('lodash')
const match = _.curry(function(reg,str){
return str.match(reg)
})
const haveSpace = match(/\s+/g)
const haveNumber = match(/\d+/g)
const filter = _.curry((func,array) => array.filter(func))
const findSpace = filter(haveSpace)
findSpace(['hello world','jkl'])
柯里化實現原理
function curry(func){
return function curriedFn(...args){
// 判斷實參和形參的個數
if(args.length < func.length){
return function (){
return curriedFn(...args.concat(Array.form(arguments)))
}
}
return func(...args)
}
}
柯里化總結
- 柯里化可以讓我們給一個函式傳遞較少的引數得到一個已經記住了某些固定引數的新函式
- 這是一種對函式引數的‘快取’
- 讓函式變得更靈活,讓函式的粒度更小
- 可以把多元函式轉化為一元函式,可以組合使用引數產生強大的功能
函式組合 Compose
純函式和柯里化很容易寫出洋蔥程式碼h(g(f(x)))
- 獲取陣列的最後一個元素再轉換成大寫字母
_.toUpper(_.first(_.reverse(array)))
函式組合可以讓我們把細粒度的函式重新組合生成一個新的函式
管道
下面這張圖表示程式中使用函式處理資料的過程,給fn函式輸入引數a,返回結果b,可以想到a資料通過管道得到了b資料
當函式fn比較複雜的時候,我們可以把函式fn拆成多個小函式,此時多了中間運算過程產生的m和n
下面這張圖可以想象成把fn這個管道拆分成3個管道f1,f2,f3,資料a通過管道f3得到結果m,m再通過管道f2得到結果n,n通過管道f1得到最終的結果b
fn = compose(f1,f2,f3)
b = fn(a)
函式組合概念
函式組合(compose) 如果一個函式經過多個函式處理才能得到最終值,這個時候可以把中間過程的函式合併成一個函式
- 函式就像是一個數據的管道,函式組合就是把這些管道連線起來,讓資料穿過多個管道形成最終結果
- 函式組合預設是從右到左執行
// 組合函式演示
// 獲取陣列的最後一元素
function compose(f, g){
return function(value){
f(g(value))
}
}
function reverse(array){
return array.reverse()
}
function first(array){
return array[0]
}
const last = compose(first, reverse)
console.log(last([1,2,3,4]))
Lodash中的組合函式
- lodash中提供組合函式flow()或者flowRight(),他們可以組合多個函式
- flow()是從左到右執行
- flowRight()是從右到左執行,使用更多一些
// lodash 中的 _.flowRight()
const _ = require('lodash')
const reverse = array => array.reverse()
const first = array => array[0]
const toUpper = array => array.toUpperCase()
const fn = _.flowRight(toUpper, first, reverse)
console.log(fn(['one, 'two', 'three']))
flowRight的實現原理
function compose(...args){
return function(value){
return args.reverse().reduce(function(acc,fn){
return fn(acc)
}, value)
}
}
// es6改寫
const compose = (...args) => value => args.reverse().reduce( (acc,fn )=> fn(acc), value)
函式組合
結合律
函式組合要滿足結合律
- 我們既可以把g和h組合,還可以把f和g組合,結果都是一樣的
// 結合律
const f = _.flowRight(_.toUpper, _.first, _.reverse)
const f = _.flowRight(_.flowRight(_.toUpper,_.first),_.reverse)
const f = _.flowRight(_.toUpper,_.flowRight(_.first, _.reverse))
除錯
// NEVER SAY DIE --> never-say-die
// 思路:通過過濾空格,將字串轉化為陣列,把陣列的每一項變為小寫,通過字串‘-’分割陣列
const split = _.curry((sep,str) => _.split(str,sep))
const map = _.curry((fn,array)=> _.map(array,fn))
const join = _.curry((spe,array)=>_.join(array,spe))
// 除錯的時候可以寫一個輔助函式,看上一個函式的執行結果,並把執行結果返回給下一個待執行的函式
const log = v => {
console.log(v)
return v
}
// 改造log
const trace = _.curry((tag,v)=>{
console.log(tag,v)
return v
})
const fn = _.flowRight( join('-'), trace('在map之後列印的'),map(_.toLower), log, split(' '))
console.log('NEVER SAY DIE')
Lodash中的FP模組
- lodash的fp模組提供了實用的對函數語言程式設計友好的方法
- 提供了不可變 已經被柯里化的(auto-curried)並且遵循函式有先(iteratee-first) 資料滯後(data-last)的方法
const _ = require('lodash')
// lodash 模組中資料優先函式置後
_.map(['a','b','c'],_.toUpper)
_.split('hello world', ' ')
const fp = require('lodash/fp')
// fp模組中函式優先,資料置後
fp.map(fp.toUpper,['a','b','c'])
fp.map(fp.toUpper)(['a','b','c'])
fp.split(' ', 'hello world')
fp.split(' ')('hello world')
// 通過fp改造之前的Never SAY DIE -> nerver-say-die 案例
const fp = require('lodash/fp')
const fn = fp.flowRight(fp.join('-'),fp.map(fp.toLower,fp.split(' ')))
lodash 中的map和fp中的map區別
- lodash中的map後面的function接收三個引數 value:any, index|key, array
- fp中的map中function只接收一個引數 value:any
Point Free
定義:我們可以把資料處理的過程定義成與資料無關的合成運算,不需要用到代表資料的那個引數,只需要簡單的把運算步驟合成到一起,在使用這種模式之前我們需要定義一些輔助的基本運算函式
- 不需要指明處理的資料
- 只需要合成運算的過程
- 需要定義一些輔助的基本運算函式
// Hello World -> hello_world
const fp = require('lodash/fp')
const f = fp.flowRight(fp.replace(/s+/g, '_'), fp.toLower)
案例
// world wild web ==> W. W. W.
const firstLetterToUpper = fp.flowRight(fp.join('. '), fp.map(fp.first), fp.map(fp.toUpper), fp.split(' '))
// 改造
const firstLetterToUpper = fp.flowRight(fp.join('. '), fp.map(fp.flowRight(fp.first,fp.toUpper)), fp.split(' '))
函子Functor
為什麼學習函子
到目前為止已經學習了函數語言程式設計的一些基礎,但是我們還沒有演示在函數語言程式設計中如何把副作用控制到可控範圍內、異常處理、非同步操作等
什麼是Functor
- 容器:包含值和值得變形關係(這個變形關係就是函式)
- 函子:是一個特殊的容器,通過一個普通的物件來實現,該物件具有map方法,map方法可以執行一個函式,對值進行處理(變形關係)
// Functor 函子
class Container { // 建立函子類
constructor (value) {
this._value = value // 接收一個value,儲存在內部
}
map(fn){ // 內部有個map方法接收一個純函式,處理完資料並返回一個函子物件
return new Container(fn(this._value))
}
}
// 呼叫
let r = new Container(5)
.map(x => x + 1)
.map(x => x * x)
console.log(r) // 36
// 對上面的class 類進行改造,封裝靜態方法of, new Container建立函子物件
class Container {
static of (value){
return new Container(value)
}
constructor (value) {
this._value = value // 接收一個value,儲存在內部屬性_value中
}
map(fn){ // 內部有個map方法接收一個純函式,處理完資料並返回一個函子物件
return Container.of(fn(this._value))
}
}
函子總結
- 函數語言程式設計的運算不直接操作值,而是由函子完成
- 函子就是一個實現了map契約的物件
- 我們可以把函子想象成一個盒子,這個盒子裡封裝了一個值
- 想要處理盒子中的值,我們需要給盒子的map方法傳遞一個處理值的函式(純函式),由這個函式對值進行處理
- 最終map方法返回一個包含新值得盒子(函子)
// 演示 null undefined 的問題
Container.of(null)
.map(x => x.toUpperCase())
// 傳遞null會報錯,不符合純函式的特徵(相同的輸入會有相同的輸出)
MayBe函子
- 我們再程式設計的過程中可能會處理很多錯誤,需要對這些錯誤做相應的處理
- MayBe 函子的作用就是可以對外部的空值情況做處理(控制副作用在允許的範圍)
class MayBe {
static of(value){
return new MayBe(value)
}
constructor(value){
this._value = value
}
map(fn){
return this.isNothing() ? MayBe.of(null) : MayBe.of(fn(value))
}
isNothing(){
return this._value === null || this._value === undefined
}
}
// MayBe 函子的問題
let r = MayBe.of('hello world')
.map(x => x.toUpperCase())
.map(null)
.map(x => x.split(' '))
console.log(r) // MayBe{_value: null }
// 不知道是什麼地方出現了null
Either函子
- Either兩者中的任何一個,類似於 if…else 的處理
- 異常會讓函式變得不存,Either函子可以用來做異常處理
class Left{
static of(value){
return new Left(value)
}
constructor(value){
this._value = value
}
map(fn){
return this
}
}
class Right{
static of(value){
return new Right(value)
}
constructor(value){
this._value = value
}
map(fn){
return Right.of(fn(this._value))
}
}
function parseJSON(str){
try{
return Right(JSON.parse(str))
}catch(e){
return Left({error:e.message})
}
}
const r = parseJSON('{name:zs}')
console.log(r) // 走Left
IO函子
- IO函子中的_value是一個函式,這裡是把函式作為值來處理
- IO函子可以把不純的動作儲存到_value中,延遲執行這個不純的操作(惰性執行)
- 把不純的操作交給呼叫者來處理
const fp = require('lodash/fp')
class IO {
static of(x){
return new IO(function(){
return x
})
}
constructor(fn){
this._value = fn
}
map(fn){
return new IO(fp.flowRight(fn,this._value))
}
}
// 呼叫
let r = IO.of(process).map(p => p.exexPath)
console.log(r._value)
Monad 函子
- 是可以變扁的Pointed函子,IO(IO(x))
- 一個函子如果具有join 和 of兩個方法,並遵守一些定律就是一個Monad
const fp = require('lodash/fp')
class IO {
static of(x){
return new IO(function(){
return x
})
}
constructor(fn){
this._value = fn
}
map(fn){
return new IO(fp.flowRight(fn,this._value))
}
join() {
return this._value()
}
flatMap(fn){
return this.map(fn).join()
}
}
let readFile = function(filename) {
return new IO(function(){
return fs.readFileSync(filename, 'utf-8')
})
}
let print = function (x) {
return new IO(function(){
console.log(x)
return x
})
}
let r = readFileSync('package.json')
.flatMap(print)