一個持續更新的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、純函式
大多數函數語言程式設計的好處來自於編寫純函式,純函式是對給定的輸入返回相同的輸出的函式,並且純函式不應依賴任何外部變數,也不應改變任何外部變數。
純函式的好處
- 純函式產生容易測試的程式碼
- 純函式容易寫出合理的程式碼
- 純函式容易寫出併發程式碼
純函式總是允許我們併發的執行程式碼。因為純函式不會改變它的環境,這意味著我們根本不需要擔心同步問題。
- 純函式的輸出結果可快取
既然純函式總是為給定的輸入返回相同的輸出,那麼我們就能夠快取函式的輸出。
高階函式
資料和資料型別
程式作用於資料,資料對於程式的執行很重要。每種程式語言都有資料型別。這些資料型別能夠儲存資料並允許程式作用其中。
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的理念
- 每個程式只做好一件事情,為了完成一項新的任務,重新構建要好於在複雜的舊程式中新增新“屬性”。
- 每個程式的輸出應該是另一個尚未可知的程式的輸入。
- 每一個基礎函式都需要接受一個引數並返回資料。
組合(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
如上面所示,我們程式碼中經常需要判斷一些null
和undefined
的情況。下面我們來看一下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、例項
文中所有的概念對應的例項可以在 https://github.com/qiqihaobenben/learning-functional 獲取,可以開啟對應的註釋來實際執行一下。
3、薦書
《JavaScript ES6 函數語言程式設計入門經典》,強烈建議想入門函數語言程式設計的同學看一下,書有點老,可以略過工具介紹之類的,關鍵看其內在的思想,最重要的是,這本書很薄,差不多跟一本漫畫書類似。