JavaScript函數式編程入門經典

一個持續更新的github筆記,連接地址:Front-End-Basics,能夠watch,也能夠star。javascript

此篇文章的地址:JavaScript函數式編程入門經典html


正文開始java



什麼是函數式編程?爲什麼它重要?

數學中的函數

f(x) = y
// 一個函數f,以x爲參數,並返回輸出y
複製代碼

關鍵點:git

  • 函數必須老是接受一個參數
  • 函數必須老是返回一個值
  • 函數應該依據接收到的參數(例如x)而不是外部環境運行
  • 對於一個給定的x,只會輸出惟一的一個y

函數式編程技術主要基於數學函數和它的思想,因此要理解函數式編程,先了解數學函數是有必要的。github

函數式編程的定義

函數是一段能夠經過其名稱被調用的代碼。它能夠接受參數,並返回值。面試

與面向對象編程(Object-oriented programming)和過程式編程(Procedural programming)同樣,函數式編程(Functional programming)也是一種編程範式。咱們可以以此建立僅依賴輸入就能夠完成自身邏輯的函數。這保證了當函數被屢次調用時仍然返回相同的結果(引用透明性)。函數不會改變任何外部環境的變量,這將產生可緩存的,可測試的代碼庫。編程

函數式編程具備如下特徵

一、引用透明性

全部的函數對於相同的輸入都將返回相同的值,函數的這一屬性被稱爲引用透明性(Referential Transparency)數組

// 引用透明的例子,函數identity不管輸入什麼,都會原封不動的返回
var identity = (i) => {return i}
複製代碼
替換模型

把一個引用透明的函數用於其餘函數調用之間。緩存

sum(4,5) + identity(1)bash

根據引用透明的定義,咱們能夠把上面的語句換成:

sum(4,5) + 1

該過程被稱爲替換模型(Substitution Model),由於函數的邏輯不依賴其餘全局變量,你能夠直接替換函數的結果,這與它的值是同樣的。因此,這使得併發代碼緩存成爲可能。

併發代碼: 併發運行的時候,若是依賴了全局數據,要保證數據一致,必須同步,並且必要時須要鎖機制。遵循引用透明的函數只依賴參數的輸入,因此能夠自由的運行。

緩存: 因爲函數會爲給定的輸入返回相同的值,實際上咱們就能緩存它了。好比實現一個計算給定數值的階乘的函數,咱們就能夠把每次階乘的結果緩存下來,下一次直接用,就不用計算了。好比第一次輸入5,結果是120,第二次輸入5,咱們知道結果必然是120,因此就能夠返回已緩存的值,而沒必要再計算一次。

二、聲明式和抽象

函數式編程主張聲明式編程和編寫抽象的代碼。

比較命令式和聲明式
// 有一個數組,要遍歷它並把它打印到控制檯

/*命令式*/
var array = [1,2,3]
for(var i = 0; i < array.length; i++)
console(array[i]) // 打印 1,2,3

// 命令式編程中,咱們精確的告訴程序應該「如何」作:獲取數組的長度,經過數組的長度循環數組,在每一次循環中用索引獲取每個數組元素,而後打印出來。
// 可是咱們的任務只是打印出數組的元素。並非要告訴編譯器要如何實現一個遍歷。



/*聲明式*/
var array = [1,2,3]
array.forEach((element) => console.log(element)) // 打印 1,2,3

// 咱們使用了一個處理「如何」作的抽象函數,而後咱們就能只關心作「什麼」了
複製代碼
函數式編程主張以抽象的方式建立函數,例如上文的forEach,這些函數可以在代碼的其餘部分被重用。

三、純函數

大多數函數式編程的好處來自於編寫純函數,純函數是對給定的輸入返回相同的輸出的函數,而且純函數不該依賴任何外部變量,也不該改變任何外部變量。

純函數的好處
  1. 純函數產生容易測試的代碼
  2. 純函數容易寫出合理的代碼
  3. 純函數容易寫出併發代碼 純函數老是容許咱們併發的執行代碼。由於純函數不會改變它的環境,這意味着咱們根本不須要擔憂同步問題。
  4. 純函數的輸出結果可緩存 既然純函數老是爲給定的輸入返回相同的輸出,那麼咱們就可以緩存函數的輸出。

高階函數

數據和數據類型

程序做用於數據,數據對於程序的執行很重要。每種編程語言都有數據類型。這些數據類型可以存儲數據並容許程序做用其中。

JavaScript中函數是一等公民(First Class Citizens)

**當一門語言容許函數做爲任何其餘數據類型使用時,函數被稱爲一等公民。**也就是說函數可被賦值給變量,做爲參數傳遞,也可被其餘函數返回。

函數做爲JavaScript的一種數據類型,因爲函數是相似String的數據類型,因此咱們能把函數存入一個變量,可以做爲函數的參數進行傳遞。因此JavaScript中函數是一等公民。

高階函數的定義

接受另外一個函數做爲其參數的函數稱爲高階函數(Higher-Order-Function),或者說高階函數是接受函數做爲參數而且/或者返回函數做爲輸出的函數。

抽象和高階函數

通常而言,高階函數一般用於抽象通用的問題,換句話說,高階函數就是定義抽象。

抽象 : 在軟件工程和計算機科學中,抽象是一種管理計算機系統複雜性的技術。 經過創建一我的與系統進行交互的複雜程度,把更復雜的細節抑制在當前水平之下。簡言之,抽象讓咱們專一於預約的目標而無須關心底層的系統概念。

例如:你在編寫一個涉及數值操做的代碼,你不會對底層硬件的數字表現方式究竟是16位仍是32位整數有很深的瞭解,包括這些細節在哪裏屏蔽。由於它們被抽象出來了,只留下了簡單的數字給咱們使用。

// 用forEach抽象出遍歷數組的操做
const forEach = (array,fn) => {
  let i;
  for(i=0;i<array.length;i++) {
    fn(array[i])
  }
}

// 用戶不須要理解forEach是如何實現遍歷的,如此問題就被抽象出來了。
//例如,想要打印出數組的每一項
let array = [1,2,3]
forEach(array,(data) => console.log(data)) 
複製代碼

閉包和高階函數

什麼是閉包?簡言之,**閉包就是一個內部函數。**什麼是內部函數?就是在另外一個函數內部的函數。

閉包的強大之處在於它對做用域鏈(或做用域層級)的訪問。從技術上講,閉包有3個可訪問的做用域。

(1) 在它自身聲明以內聲明的變量

(2) 對全局變量的訪問

(3) 對外部函數變量的訪問(關鍵點)

實例一:假設你再遍歷一個來自服務器的數組,並發現數據錯了。你想調試一下,看看數組裏面究竟包含了什麼。不要用命令式的方法,要用函數式的方法來實現。這裏就須要一個 tap 函數。

const tap = (value) => {
  return (fn) => {
    typeof fn === 'function' && fn(value)
    console.log(value)
  }
} 

// 沒有調試以前
forEach(array, data => {
  console.log(data + data)
})

// 在 forEach 中使用 tap 調試
forEach(array, data => {
  tap(data)(() => {
    console.log(data + data)
  })
})
複製代碼

完成一個簡單的reduce函數

const reduce = (array,fn,initialValue) => {
  let accumulator;
  if(initialValue != undefined)
    accumulator = initialValue
  else
    accumulator = array[0]

  if(initialValue === undefined)
    for(let i = 1; i < array.length; i++)
      accumulator = fn(accumulator, array[i])
  else
    for(let value of array)
      accumulator = fn(accumulator,value)
  return accumulator
}

console.log(reduce([1,2,3], (accumulator,value) => accumulator + value))
// 打印出6
複製代碼

柯里化與偏應用

一些概念

一元函數

只接受一個參數的函數稱爲一元(unary)函數。

二元函數

只接受兩個參數的函數稱爲二元(binary)函數。

變參函數

變參函數是接受可變數量的函數。

柯里化

柯里化是把一個多參數函數轉換爲一個嵌套的一元函數的過程。

例如

// 一個多參數函數
const add = (x,y) => x + y;
add(2,3)

// 一個嵌套的一元函數
const addCurried = x => y => x + y;
addCurried(2)(3)

// 而後咱們寫一個高階函數,把 add 轉換成 addCurried 的形式。
const curry = (binaryFn) => {
  return function (firstArg) {
    return function (secondArg) {
      return binaryFn(firstArg,secondArg)
    }
  }
}
let autoCurriedAdd = carry(add)
autoCurriedAdd(2)(3)
複製代碼

上面只是簡單實現了一個二元函數的柯里化,下面咱們要實現一個更多參數的函數的柯里化。

const curry = (fn) => {
  if (typeof fn !== 'function') {
    throw Error('No function provided')
  }
  return function curriedFn (...args) {
    // 判斷當前接受的參數是否是小於進行柯里化的函數的參數個數
    if(args.length < fn.length) {
      // 若是小於的話就返回一個函數再去接收剩下的參數
      return function (...argsOther) {
        return curriedFn.apply(null, args.concat(argsOther))
      }
    }else {
      return fn.apply(null,args)
    }
  }
}

 const multiply = (x,y,z) => x * y * z;
 console.log(curry(multiply)(2)(3)(4))
複製代碼

柯里化的應用實例:從數組中找出含有數字的元素

let match = curry(function (expr,str) {
  return str.match(expr)
})
let hasNumber = match(/[0-9]+/)

let initFilter = curry(function (fn,array) {
  return array.filter(fn)
})

let findNumberInArray = initFilter(hasNumber)
console.log(findNumberInArray(['aaa', 'bb2', '33c', 'ddd', ]))
// 打印 [ 'bb2', '33c' ]
複製代碼

偏應用

咱們上面設計的柯里化函數老是在最後接受一個數組,這使得它能接受的參數列表只能是從最左到最右。

可是有時候,咱們不能按照從左到右的這樣嚴格傳入參數,或者只是想部分地應用函數參數。這裏咱們就須要用到偏應用這個概念,它容許開發者部分地應用函數參數。

const partial = function (fn, ...partialArgs) {
  return function (...fullArguments) {
    let args = partialArgs
    let arg = 0;
    for(let i = 0; i < args.length && arg < fullArguments.length; i++) {
      if(args[i] === undefined) {
        args[i] = fullArguments[arg++]
      }
    }
    return fn.apply(null,args)
  }
}
複製代碼

偏應用的示例:

// 打印某個格式化的JSON
let prettyPrintJson = partial(JSON.stringify,undefined,null,2)
console.log(prettyPrintJson({name:'fangxu',gender:'male'}))

// 打印出
{
  "name": "fangxu",
  "gender": "male"
}
複製代碼

組合與管道

Unix的理念

  1. 每一個程序只作好一件事情,爲了完成一項新的任務,從新構建要好於在複雜的舊程序中添加新「屬性」。
  2. 每一個程序的輸出應該是另外一個還沒有可知的程序的輸入。
  3. 每個基礎函數都須要接受一個參數並返回數據。

組合(compose)

const compose = (...fns) => {
  return (value) => reduce(fns.reverse(),(acc,fn) => fn(acc), value)
}
複製代碼

compose 組合的函數,是按照傳入的順序從右到左調用的。因此傳入的 fns 要先 reverse 一下,而後咱們用到了reduce ,reduce 的累加器初始值是 value ,而後會調用 (acc,fn) => fn(acc), 依次從 fns 數組中取出 fn ,將累加器的當前值傳入 fn ,即把上一個函數的返回值傳遞到下一個函數的參數中。

組合的實例:

let splitIntoSpace = (str) => str.split(' ')
let count = (array) => array.length
const countWords = composeN(count, splitIntoSpace)
console.log(countWords('make smaller or less in amount'))
// 打印 6
複製代碼

管道/序列

compose 函數的數據流是從右往左的,最右側的先執行。固然,咱們還可讓最左側的函數先執行,最右側的函數最後執行。這種從左至右處理數據流的過程稱爲管道(pipeline)或序列(sequence)。

// 跟compose的區別,只是沒有調用fns.reverse()
const pipe = (...fns) => (value) => reduce(fns,(acc,fn) => fn(acc),value)
複製代碼

函子

什麼是函子(Functor)?

定義:函子是一個普通對象(在其它語言中,多是一個類),它實現了map函數,在遍歷每一個對象值的時候生成一個新對象。

實現一個函子

一、簡言之,函子是一個持有值的容器。並且函子是一個普通對象。咱們就能夠建立一個容器(也就是對象),讓它可以持有任何傳給它的值。

const Container = function (value) {
  this.value = value
}

let testValue = new Container(1)
// => Container {value:1}
複製代碼

咱們給 Container 增長一個靜態方法,它能夠爲咱們在建立新的 Containers 時省略 new 關鍵字。

Container.of = function (value) {
  return new Container(value)
}

// 如今咱們就能夠這樣來建立
Container.of(1)
// => Container {value:1}
複製代碼

二、函子須要實現 map 方法,具體的實現是,map 函數從 Container 中取出值,傳入的函數把取出的值做爲參數調用,並將結果放回 Container。

爲何須要 map 函數,咱們上面實現的 Container 僅僅是持有了傳給它的值。可是持有值的行爲幾乎沒有任何應用場景,而 map 函數發揮的做用就是,容許咱們使用當前 Container 持有的值調用任何函數。

Container.prototype.map = function (fn) {
  return Container.of(fn(this.value))
}

// 而後咱們實現一個數字的 double 操做
let double = (x) => x + x;
Container.of(3).map(double)
// => Container {value: 6}
複製代碼

三、map返回了一傳入函數的執行結果爲值的 Container 實例,因此咱們能夠鏈式操做。

Container.of(3).map(double).map(double).map(double)
// => Container {value: 24}
複製代碼

經過以上的實現,咱們能夠發現,函子就是一個實現了map契約的對象。函子是一個尋求契約的概念,該契約很簡單,就是實現 map 。根據實現 map 函數的方式不一樣,會產生不一樣類型的函子,如 MayBe 、 Either

函子能夠用來作什麼?以前咱們用tap函數來函數式的解決代碼報錯的調試問題,如何更加函數式的處理代碼中的問題,那就須要用到下面咱們說的MayBe函子

MayBe 函子

讓咱們先寫一個upperCase函數來假設一種場景

let value = 'string';
function upperCase(value) {
  // 爲了不報錯,咱們得寫這麼一個判斷
  if(value != null || value != undefined)
    return value.toUpperCase()
}
upperCase(value)
// => STRING
複製代碼

如上面所示,咱們代碼中常常須要判斷一些nullundefined的狀況。下面咱們來看一下MayBe函子的實現。

// MayBe 跟上面的 Container 很類似
export const MayBe = function (value) {
  this.value = value
}
MayBe.of = function (value) {
  return new MayBe(value)
}
// 多了一個isNothing
MayBe.prototype.isNoting = function () {
  return this.value === null || this.value === undefined;
}
// 函子一定有 map,可是 map 的實現方式可能不一樣
MayBe.prototype.map = function(fn) {
  return this.isNoting()?MayBe.of(null):MayBe.of(fn(this.value))
}

// MayBe應用
let value = 'string';
MayBe.of(value).map(upperCase)
// => MayBe { value: 'STRING' }
let nullValue = null
MayBe.of(nullValue).map(upperCase)
// 不會報錯 MayBe { value: null }
複製代碼

Either 函子

MayBe.of("tony")
  .map(() => undefined)
  .map((x)f => "Mr. " + x)
複製代碼

上面的代碼結果是 MyaBe {value: null},這只是一個簡單的例子,咱們能夠想一下,若是代碼比較複雜,咱們是不知道究竟是哪個分支在檢查 undefined 和 null 值時執行失敗了。這時候咱們就須要 Either 函子了,它能解決分支拓展問題。

const Nothing = function (value) {
  this.value = value;
}
Nothing.of = function (value) {
  return new Nothing(value)
}
Nothing.prototype.map = function (fn) {
  return this;
}
const Some = function (value) {
  this.value = value;
}
Some.of = function (value) {
  return new Some(value)
}
Some.prototype.map = function (fn) {
  return Some.of(fn(this.value));
}

const Either = {
  Some,
  Nothing
}

複製代碼

Pointed 函子

函子只是一個實現了 map 契約的接口。Pointed 函子也是一個函子的子集,它具備實現了 of 契約的接口。 咱們在 MayBe 和 Either 中也實現了 of 方法,用來在建立 Container 時不使用 new 關鍵字。因此 MayBe 和 Either 均可稱爲 Pointed 函子。

ES6 增長了 Array.of, 這使得數組成爲了一個 Pointed 函子。

Monad 函子

MayBe 函子極可能會出現嵌套,若是出現嵌套後,咱們想要繼續操做真正的value是有困難的。必須深刻到 MayBe 內部進行操做。

let joinExample = MayBe.of(MayBe.of(5));
// => MayBe { value: MayBe { value: 5 } }

// 這個時候咱們想讓5加上4,須要深刻 MayBe 函子內部
joinExample.map((insideMayBe) => {
  return insideMayBe.map((value) => value + 4)
})
// => MayBe { value: MayBe { value: 9 } }
複製代碼

咱們這時就能夠實現一個 join 方法來解決這個問題。

// 若是經過 isNothing 的檢查,就返回自身的 value
MayBe.prototype.join = function () {
  return this.isNoting()? MayBe.of(null) : this.value
}
複製代碼
let joinExample2 = MayBe.of(MayBe.of(5));
// => MayBe { value: MayBe { value: 5 } }

// 這個時候咱們想讓5加上4就很簡單了。
joinExample2.join().map((value) => value + 4)
// => MayBe { value: 9 }
複製代碼

再延伸一下,咱們擴展一個 chain 方法。

MayBe.prototype.chain = function (fn) {
  return this.map(fn).join()
}
複製代碼

調用 chain 後就能把嵌套的 MayBe 展開了。

let joinExample3 = MayBe.of(MayBe.of(5));
// => MayBe { value: MayBe { value: 5 } }


joinExample3.chain((insideMayBe) => {
  return insideMayBe.map((value) => value + 4)
})
// => MayBe { value: 9 }
複製代碼

Monad 其實就是一個含有 chain 方法的函子。只有of 和 map 的 MayBe 是一個函子,含有 chain 的函子是一個 Monad。

總結

JavaScript是函數式編程語言嗎?

函數式編程主張函數必須接受至少一個參數並返回一個值,可是JavaScript容許咱們建立一個不接受參數而且實際上什麼也不返回的函數。因此JavaScript不是一種純函數語言,更像是一種多範式的語言,不過它很是適合函數式編程範式。

補充

一、純函數是數學函數

function generateGetNumber() {
  let numberKeeper = {}
  return function (number) {
    return numberKeeper.hasOwnProperty(number) ? 
    number : 
    numberKeeper[number] = number + number
  }
}
const getNumber = generateGetNumber()
getNumber(1)
getNumber(2)
……
getNumber(9)
getNumber(10)

// 此時numberKeeper爲:
{
  1: 2
  2: 4
  3: 6
  4: 8
  5: 10
  6: 12
  7: 14
  8: 16
  9: 18
  10: 20
}
複製代碼

如今咱們規定,getNumber只接受1-10範圍的參數,那麼返回值確定是 numberKeeper 中的某一個 value 。據此咱們分析一下 getNumber ,該函數接受一個輸入併爲給定的範圍(此處範圍是10)映射輸出。輸入具備強制的、相應的輸出,而且也不存在映射兩個輸出的輸入。

下面我來再看一下數學函數的定義(維基百科)

在數學中,函數是一種輸入集合和可容許的輸出集合之間的關係,具備以下屬性:每一個輸入都精確地關聯一個輸出。函數的輸入稱爲參數,輸出稱爲值。對於一個給定的函數,全部被容許的輸入集合稱爲該函數的定義域,而被容許的輸出集合稱爲值域。

根據咱們對於 getNumber 的分析,對照數學函數的定義,會發現徹底一致。咱們上面的getNumber函數的定義域是1-10,值域是2,4,6,……18,20

二、實例

文中全部的概念對應的實例能夠在 github.com/qiqihaobenb… 獲取,能夠打開對應的註釋來實際執行一下。

三、薦書

《JavaScript ES6 函數式編程入門經典》,強烈建議想入門函數式編程的同窗看一下,書有點老,能夠略過工具介紹之類的,關鍵看其內在的思想,最重要的是,這本書很薄,差很少跟一本漫畫書相似。

四、推薦文章(非引用文章)

  1. 漫談 JS 函數式編程(一)
  2. 從一道坑人的面試題說函數式編程
  3. 函數式編程入門教程
  4. 函數式編程的一點實戰
相關文章
相關標籤/搜索