JavaScript的函數式編程簡介

函數式編程的優點

  • 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();
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章