函數式編程的優點
- React的推廣、Vue3開始採用
- 可以拋棄模擬面向對象編程的this
- 打包時,利用sideEffects來過濾副作用代碼
- 方便測試,並行處理
- lodash、underscore、ramda等庫幫助開發
- 與數據庫查詢語言的語法非常相似,擅長處理數據
函數式編程的概念
編程範式,對運算過程的抽象,將程序的本質(輸入通過運算得到輸出的過程)進行進一步的抽象,把運算過程中各種運算進行更加細粒度的劃分爲各種各樣的函數,然後用函數的組合方式來抽象得到整個運算。這樣有利於複用代碼,靈活組合出不同的運算過程。
函數一詞指的是數學中的函數定義,即輸入和輸出的映射關係
JavaScript中的函數
函數是一等公民
- 用變量存儲函數
- 用函數作爲參數
- 用函數作爲返回值
高階函數
概念
高階函數可以接收函數作爲參數,可以返回函數作爲返回值。
將通用的運算過程封裝在高階函數內部,通過傳入其他函數作爲參數來實現各種不同的處理結果。
常用高階函數
- forEach
- map
- filter
- every
- some
- find/findIndex
- reduce
- sort
lambda表達式(即箭頭函數)
(A) => B; // 形如該語法的就是lambda表達式,即ES6中的箭頭函數,可以用相對簡潔的語法來聲明一個匿名函數
因爲函數的本質就是映射輸入到輸出,所以lambda表達式可以很清晰的顯示這一點;
閉包Closure
概念
當函數調用之後,調用函數的外部環境依然有對函數內部成員的引用,此時內部成員就成爲了閉包變量,被調用的函數不會釋放內部成員的內存。一般都是從一個外部函數返回一個定義在其中的內部函數的場景。
純函數
概念
相同的輸入,相同的輸出
優點
- 返回結果可以緩存:只要輸入不變,輸出就不變,可以用緩存結果。Vue2.0中的計算屬性就可以利用緩存。**lodash庫中的memoize()**方法可以去緩存純函數執行的結果。
- 可測試
- 並行處理:純函數不訪問共享數據,只依賴於傳入的參數,因此可以在並行環境(web worker)下任意運行純函數
副作用
函數的輸出不僅依賴於內部環境,還依賴於外部環境,則此時函數無法保證輸出只與輸入相關,函數具有副作用,副作用代碼就是依賴於外部環境的代碼。
副作用來源
所有的外部交互都可能帶來副作用
- 配置文件
- 數據庫
- 用戶輸入
副作用的問題
- 降低函數的通用性
- 給程序帶來安全隱患
- 給程序造成不易察覺的bug
副作用可控
副作用不可避免,要爭取將副作用控制在一定範圍內。
方法鏈式調用與函數管道化
鏈式調用方法
定義:可以對某一種數據類型進行連續的鏈式調用該類型的各種方法,從而對該數據進行很複雜的處理行爲,並且使得函數式聲明形式的代碼更加易讀。鏈式調用的前提是方法需要返回該數據的實例。
缺點:鏈式調用存在着緊耦合,也就是說鏈式調用的方法必須是該數據類型支持的方法,無法對該數據類型應用任意的函數來處理。
函數管道化
定義:將任意函數組合成有序的函數序列,前一個函數的輸出作爲後一個函數的輸入,這樣擺脫了數據類型與方法的耦合問題,使得對任意數據可以採用任意的函數來處理。這也是函數式編程重操作、輕數據的體現。
注意:爲了實現管道化,被連接在一起的函數需要在參數數量(稱爲元數arity)和類型上互相兼容。
兼容條件
- 類型:函數返回類型與接收函數的參數類型相匹配
- 元數:接收函數必須聲明至少一個參數才能處理上一個函數的返回值。元數被稱爲函數的length,函數的length屬性可以拿到元數。
單一參數的純函數
函數的複雜性與函數的參數數量息息相關。 單一參數的純函數簡單,也滿足函數的單一職責的編程原則。可以採用元組的數據結構(JavaScript沒有元組的定義,需要自定義元組類型)來解決將多元參數變爲一元參數的問題,或者採用函數的柯里化來將多元函數轉換爲一元函數。
柯里化(Haskell Brooks Curry)
概念
將多元函數轉換爲可以接收一元、二元等不定參數的函數,每次傳入參數調用後返回一個新的函數,直到最終所接收的參數長度與原來函數長度一致時,才返回多元函數的調用結果。
- 柯里化使得所有參數在被提供之前,可以掛起或延遲執行原函數
- 柯里化使得原函數實現惰性求值,有效控制函數的執行時機
柯里化可以將函數參數進一步細粒化,由參數的值來定製不同的運算,提高函數的複用性。
lodash中的柯里化
_.curry(func)
將傳入的func包裝返回一個柯里化的函數curried。curried函數如果接收一部分參數(func函數的參數),則返回一個函數繼續接收剩餘參數;如果接收了全部參數,則返回調用結果。
模擬_.curry柯里化
function curry(func) {
// 命名curried,方便後面進行遞歸調用;
return function curried(...args) {
// 對func的形參個數(函數的lenght屬性)和柯里化後的函數傳入的實參個數作比較
if(args.length < func.length) {
return function() {
return curried(...args.concat(Array.from(arguments)));
}
} else {
// 當實參個數不小於形參個數時,則調用func函數;
return func(...args);
}
}
}
柯里化總結
- 對函數參數進行了緩存
- 函數顆粒度更小
- 多元函數轉換爲一元函數,經過對這些一元函數的組合,可以構成更加豐富的函數
函數的組合compose
純函數和柯里化容易形成洋蔥代碼,即函數之間層層嵌套h(g(f(x)))
,不易讀懂。
函數組合讓細粒度的函數重新組合生成新的函數,讓代碼更容易讀懂。
函數組合的概念
將細粒度的函數組合起來成爲一個新的函數,輸入數據經由新的函數處理得到輸出結果。如果把一個個小型函數比作小管道,那麼新生成的函數即爲拼接起來的大管道,輸入經過大管道後輸出。每個用於組合的函數都是一元函數。
函數組合一般默認從右向左依次執行。如fn = compose(f1, f2, f3)
,則fn的執行對應即爲f3、f2、f1的順序依次執行。這是爲了和洋蔥代碼的嵌套順序一致,比如前面的代碼轉換爲洋蔥代碼即爲f1(f2(f3()))
,也與賦值語句的順序一致。
lodash中用於組合函數的方法
_.flowRight()
模擬_.flowRight()
function composeFromRight(...args) {
// 接收value作爲輸入數據
return function(value) {
return args.reverse().reduce(function(accu, fn) {
return fn(accu);
}, value);
}
}
使用箭頭函數來改寫整理一下
const composeFromRight = (...args) => value => args.reverse().reduce((accu, fn) => fn(accu), value);
函數組合的結合律
結合律指的是,可以先組合一部分函數生成fc,然後再將fc與其他函數組合直到沒有剩餘函數。最後的執行結果與組合的順序無關。例如
// f1, f2, f3是等效的;
const f1 = compose(f, g, h);
const f2 = compose(compose(f, g), h);
const f3 = compose(f, compose(g, h));
函數組合的調試
經過組合生成的函數在執行中如果出現問題,則需要去定位問題出現在哪個一元函數上。通常在一元函數之間插入日誌記錄函數,用於輸出一元函數的處理結果。
Lodash中的FP模塊
- 提供了函數式編程友好的方法
- FP模塊中的方法是不可變、auto-curried、 iteratee-first, data-last的方法,之所以數據最後傳入,是因爲數據是整個組合函數要處理的,數據從處理管道一直傳遞,所以需要接收數據爲參數,返回數據,而處理函數則事先已經確定了,所以先傳。
const _ = require('lodash');
// 傳入參數時,先傳數據,後傳處理函數
_.map(['a', 'b', 'c'], _.toUpper); // ['A', 'B', 'C']
const fp = require('lodash/fp');
// 傳入參數時,先傳處理函數,再傳數據
fp.map(fp.toUpper, ['a', 'b', 'c']);
// fp.map方法是自動被curried,可以參數都傳入,也可以傳入部分參數
fp.map(fp.toUpper)(['a', 'b', 'c']);
Lodash中的map與FP模塊中的map
const _ = require('lodash');
_.map(['28', '6', '10'], parseInt); // [28, NaN, 2],原因在於每次遍歷數組時,傳遞給parseInt的第二個參數是數組的索引
const fp = require('lodash/fp');
fp.map(parseInt, ['28', '6', '10']); // [28, 6, 10],原因在於傳遞給parseInt的參數只有一個,即數組元素。fp.map將傳入的函數curried,此時parseInt就只接受第一個參數了。
PointFree風格
定義
PointFree風格即“無值風格”,也就是說,PointFree風格下的函數是對運算處理過程的抽象,本身與傳入的數據無關。
- 不需要指明需要處理的數據
- 只需要合成運算
- 需要定義一些輔助性質的基本運算函數
通過PointFree模式可以提高函數的通用性。
參考
可以參考阮一峯老師的這篇文章Pointfree 編程風格指南
案例
// 目標:world wide web => W.W.W
const fp = require('lodash/fp');
const firstLetterToUpper = fp.flowRight(fp.join('.'), fp.map(fp.first), fp.map(fp.toUpper), fp.split(' ')); // 這裏對數組做了兩次遍歷,性能會比較差;
firstLetterToUpper('world wide web'); // W.W.W
// 兩次遍歷中的操作變爲一次,這樣只遍歷一次數組即可。
const firstAndToUpper = fp.flowRight(fp.first, fp.toUpper);
函子functor
定義
函子是包裹值的容器,或者說是一種數據結構,其內部的值只能通過map方法來進行處理,然後返回一個新的同類型的函子。
意義
函子的出現是爲了安全地操作數據,將操作數據的函數通過map方法傳入,再返回一個新的函子,這樣原來函子並未發生任何改變,對數據操作的函數如果拋出異常,則會被函子內部捕獲,從而避免與外界交互產生副作用。
// 定義一個函子
class Container {
constructor(value) {
// _value私有屬性
this._value = value;
}
map(fn) {
// 通過fn處理內部值,然後傳遞給函子構造函數來返回一個新的函子,這類似於鏈式調用處理數據
return new Container(fn(this._value));
}
}
new Container(6).map(v => v + 2).map(v => v * v); // Container { _value: 64 }
// 不通過new來創建
class Container {
// 靜態方法of實際上是Monad函子的一種API規範
static of(value) {
return new Container(value);
}
constructor(value) {
// _value私有屬性
this._value = value;
}
map(fn) {
// 通過fn處理內部值,然後傳遞給函子構造函數來返回一個新的函子,這類似於鏈式調用處理數據
return Container.of(fn(this._value));
}
}
MayBe函子:處理傳入空值的函子
外部在創建函子的時候,如果傳入了空值,則會導致函子在處理數據時拋出異常。這種外部傳入值的行爲是一種副作用,我們需要對這種副作用控制在合理的範圍內。
class MayBe {
static of(value) {
return new MayBe(value);
}
constructor(value) {
this._value = value;
}
map(fn) {
// 如果值爲空值,則一直返回一個內部值爲null的函子
return this.isNothing()? MayBe.of(null): MayBe.fo(fn(this._value));
}
// 輔助函數,用於判斷傳入的值是否爲空值
isNothing() {
return this._value === null || this._value === undefined;
}
}
雖然MayBe函子可以處理內部值爲空值的情況,使得不拋出異常,但如果數據處理中有一個環節出現了空值的情況,MayBe函子是無法得知哪個環節出現的問題,也無法記錄出現的異常信息。
Either函子:
Either表示兩者中的任意一個,因此需要定義兩個函子。Either用來對傳入數據進行異常記錄,如果數據合法,則返回Either類的表示正常的子類Right,如果數據不合法,則返回Either類的表示異常的子類Left,該Left實例將一直攜帶異常數據傳遞下去。
// Either需要定義兩個函子,一個函子用來記錄異常,一個函子用來處理正常的情形,這兩個函子都繼承自Either函子
class Either {
static of(value) {
return value !== null? Right.of(value): Left.of(value);
}
// 實現傳入數據的構造函數
constructor(value) {
this._value = value;
}
}
// Left函子用來處理異常,如果給Left傳入的數據出現非法值,則直接返回該Left實例
class Left {
static of(value) {
return new Left(value);
}
map(fn) {
// 注意,這裏沒有進行fn的調用;
return this;
}
}
// Right函子處理傳入數據爲合法值的情況
class Right {
static of(value) {
return new Right(value);
}
map(fn) {
return Right.of(fn(this._value));
}
}
IO函子:用來處理不純的IO操作
- IO函子內部的_value屬性是一個函數,把函數作爲值來處理
- IO函子把不純的動作存儲到_value中,延遲執行不純操作(惰性執行)
- 把不純操作交給調用者來處理,定義run()方法來交給調用者執行。
const fp = require('lodash/fp');
class IO {
static of(x) {
return new IO(function() {
return x;
})
}
constructor(fn) {
this._value = fn;
}
map(func) {
// map返回的IO函子的_value爲各種函數的組合,包括有傳入的不純操作函數;不純操作的調用由外部來掌握
return new IO(fp.flowRight(func, this._value));
}
}
folktale函子
folktale是一個標準的函數式編程庫,提供一些函數式的操作,如compose, curry等等,一些函子如Task, Either, MayBe等等
const { compose, curry } = require('folktale/core/lamda');
// curry()接收兩個參數,第二個參數爲函數,第一個參數爲該函數需要接收的參數個數;
let fn = curry(2, (x, y) => x + y);
const { toUpper, first } = require('lodash/fp');
// func將傳入的字符串數組的第一個元素轉爲大寫;
let func = compose(toUpper, first);
folktale中的task函子處理異步任務
以folktale 2.0.3版本爲例
const { task } = require('folktale/concurrency/task');
const { split, find } = require('lodash/fp');
const fs = require('fs'); // node的fs模塊
// 讀取文件的異步操作
function readFile(filename) {
// task()方法返回一個task函子,給task()方法傳入的參數爲一個帶有副作用的函數,
// 在這裏是一個讀取文件的函數,在調用函子的run()方法時,纔會執行副作用函數;
// 這裏的操作與Promise API非常相似
return task(resolver => {
fs.readFile(fileName, 'utf-8', (err, data) => {
if(err) {
resolver.reject(err)
}
resolver.resolve(data);
})
});
}
// listen()方法監聽異步操作的狀態,並傳入異常狀態和完成狀態的回調函數
// 與Promise的then()方法相似
readFile('package.json')
.map(split('\n')) // 將split('\n')這個curried函數存儲起來
.map(find(v => v.includes('version'))) // 將find(v => v.includes('version'))這個curried函數存儲起來
.run() // 執行組合函數,按順序先開始執行異步操作,然後執行上面存儲的兩個函數
.listen({ // 監聽執行的狀態
onRejected: (err) => {
console.log(err);
},
onResolved: (data) => {
console.log(data);
}
})
Pionted函子:實現了of 靜態方法的函子,of靜態方法就是將值放入一個容器中,用來代替構造函數。
of方法用來把值放到上下文context中,即創建一個包含值的容器。
Monad函子
解決函子嵌套的情況,使得扁平化。函子嵌套指的是函數func接收函子a作爲參數並返回a,然後將這個函數func作爲函子b的參數,這樣函子b._value爲函數func, 調用func()返回函子a;數據的處理形式可能就會變爲b._value()._value()
這樣的形式;
具有join和of兩個方法且遵守一定規律的函子即爲Monad函子
// join方法調用_value()來返回函子,從而解除了函子嵌套;
// 這裏的join方法只解除了一層嵌套,可以使用遞歸的方式來解除多層嵌套,只留下嵌套最深的函子
join() {
return this._value();
}