JavaScript函數語言程式設計無痛入門

是方旭啊發表於2019-05-05

一個持續更新的github筆記,連結地址:Front-End-Basics,可以watch,也可以star。

此篇文章的地址:JavaScript函數語言程式設計入門經典


正文開始



什麼是函數語言程式設計?為何它重要?

數學中的函式

f(x) = y
// 一個函式f,以x為引數,並返回輸出y
複製程式碼

關鍵點:

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

函數語言程式設計技術主要基於數學函式和它的思想,所以要理解函數語言程式設計,先了解數學函式是有必要的。

函數語言程式設計的定義

函式是一段可以通過其名稱被呼叫的程式碼。它可以接受引數,並返回值。

與物件導向程式設計(Object-oriented programming)和程式式程式設計(Procedural programming)一樣,函數語言程式設計(Functional programming)也是一種程式設計正規化。我們能夠以此建立僅依賴輸入就可以完成自身邏輯的函式。這保證了當函式被多次呼叫時仍然返回相同的結果(引用透明性)。函式不會改變任何外部環境的變數,這將產生可快取的,可測試的程式碼庫。

函數語言程式設計具有以下特徵

1、引用透明性

所有的函式對於相同的輸入都將返回相同的值,函式的這一屬性被稱為引用透明性(Referential Transparency)

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

把一個引用透明的函式用於其他函式呼叫之間。

sum(4,5) + identity(1)

根據引用透明的定義,我們可以把上面的語句換成:

sum(4,5) + 1

該過程被稱為替換模型(Substitution Model),因為函式的邏輯不依賴其他全域性變數,你可以直接替換函式的結果,這與它的值是一樣的。所以,這使得併發程式碼快取成為可能。

併發程式碼: 併發執行的時候,如果依賴了全域性資料,要保證資料一致,必須同步,而且必要時需要鎖機制。遵循引用透明的函式只依賴引數的輸入,所以可以自由的執行。

快取: 由於函式會為給定的輸入返回相同的值,實際上我們就能快取它了。比如實現一個計算給定數值的階乘的函式,我們就可以把每次階乘的結果快取下來,下一次直接用,就不用計算了。比如第一次輸入5,結果是120,第二次輸入5,我們知道結果必然是120,所以就可以返回已快取的值,而不必再計算一次。

2、宣告式和抽象

函數語言程式設計主張宣告式程式設計和編寫抽象的程式碼。

比較命令式和宣告式
// 有一個陣列,要遍歷它並把它列印到控制檯

/*命令式*/
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,這些函式能夠在程式碼的其他部分被重用。

3、純函式

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

純函式的好處
  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函式,在遍歷每個物件值的時候生成一個新物件。

實現一個函子

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

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}
複製程式碼

2、函子需要實現 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}
複製程式碼

3、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不是一種純函式語言,更像是一種多正規化的語言,不過它非常適合函數語言程式設計正規化。

補充

1、純函式是數學函式

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

2、例項

文中所有的概念對應的例項可以在 github.com/qiqihaobenb… 獲取,可以開啟對應的註釋來實際執行一下。

3、薦書

《JavaScript ES6 函數語言程式設計入門經典》,強烈建議想入門函數語言程式設計的同學看一下,書有點老,可以略過工具介紹之類的,關鍵看其內在的思想,最重要的是,這本書很薄,差不多跟一本漫畫書類似。

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

  1. 漫談 JS 函數語言程式設計(一)
  2. 從一道坑人的面試題說函數語言程式設計
  3. 函數語言程式設計入門教程
  4. 函數語言程式設計的一點實戰

相關文章